feat!: release v2 (#6545)

Co-authored-by: Artur <145776561+arturict@users.noreply.github.com>
Co-authored-by: arturict <arturict@users.noreply.github.com>
Co-authored-by: Thomas <thomas@ajnart.fr>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
Co-authored-by: Danura30082 <danura30082+github@gmail.com>
Co-authored-by: Kushie <122986913+kushiemoon-dev@users.noreply.github.com>
Co-authored-by: ajnart <thomas@ajnart.fr>
Co-authored-by: ajnart <thomas@ajnart.dev>
Co-authored-by: chenjun <chenjun@foxmail.com>
Co-authored-by: Wikibear <wikibear@users.noreply.github.com>
Co-authored-by: Bergasha <bergasha@gmail.com>
Co-authored-by: leafyelin <43306196+leafyelin@users.noreply.github.com>
Co-authored-by: Lucas Araujo <lucas.lra@gmail.com>
Co-authored-by: GreenMonito <86085570+GreenMonito@users.noreply.github.com>
Co-authored-by: Nerdy-Technician <roffo@nerdy-technician.social>
Co-authored-by: Jun <34545896+jcstrong@users.noreply.github.com>
Co-authored-by: Edmond Abraham <54119952+gitedmond@users.noreply.github.com>
Co-authored-by: thrawnx <tight.bulb9384@fastmail.com>
Co-authored-by: Chris <58918656+nordada@users.noreply.github.com>
Co-authored-by: yi111 <153097222+Yi-111-a@users.noreply.github.com>
Co-authored-by: Yanis <naamaneyanis@gmail.com>
Co-authored-by: Wikibear <me@sej.de>
Co-authored-by: alessiottavo <93441886+alessiottavo@users.noreply.github.com>
This commit is contained in:
3286 files changed
+284386 -139176

No files matched your search

@@ -0,0 +1,101 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { airQualityRequestHandler } from "./air-quality";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const makeAirQualityResponse = () => {
const times = ["2026-07-01T00:00", "2026-07-01T01:00", "2026-07-02T00:00"];
const values = [10, 20, 30];
const fields = [
"european_aqi",
"us_aqi",
"uv_index",
"pm10",
"pm2_5",
"carbon_monoxide",
"nitrogen_dioxide",
"sulphur_dioxide",
"ozone",
"european_aqi_pm2_5",
"european_aqi_pm10",
"european_aqi_nitrogen_dioxide",
"european_aqi_ozone",
"european_aqi_sulphur_dioxide",
"us_aqi_pm2_5",
"us_aqi_pm10",
"us_aqi_nitrogen_dioxide",
"us_aqi_carbon_monoxide",
"us_aqi_ozone",
"us_aqi_sulphur_dioxide",
"alder_pollen",
"birch_pollen",
"grass_pollen",
"mugwort_pollen",
"olive_pollen",
"ragweed_pollen",
] as const;
return {
timezone: "Europe/Berlin",
utc_offset_seconds: 7200,
current: {
time: "2026-07-01T12:00",
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? null : 12])),
},
hourly: {
time: times,
...Object.fromEntries(fields.map((field) => [field, field === "ragweed_pollen" ? [null, null, null] : values])),
},
fields,
};
};
beforeEach(() => {
vi.clearAllMocks();
airQualityRequestHandler.invalidateCache();
});
describe("air quality request handler", () => {
it("requests and normalizes the full useful forecast field set", async () => {
const fixture = makeAirQualityResponse();
mockFetch.mockResolvedValue(new Response(JSON.stringify(fixture)));
const first = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
const second = await airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
expect(first.data.current.pollutants.pm2_5).toBe(12);
expect(first.data.current.pollen.ragweed).toBeNull();
expect(first.data.hourly[0]?.observedAt).toBe("2026-06-30T22:00:00.000Z");
expect(first.data.daily).toEqual([
{ date: "2026-07-01", europeanAqiMax: 20, usAqiMax: 20, uvIndexMax: 20, pm2_5Max: 20, pm10Max: 20 },
{ date: "2026-07-02", europeanAqiMax: 30, usAqiMax: 30, uvIndexMax: 30, pm2_5Max: 30, pm10Max: 30 },
]);
expect(second.data).toStrictEqual(first.data);
expect(mockFetch).toHaveBeenCalledOnce();
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
expect(requestUrl.origin).toBe("https://air-quality-api.open-meteo.com");
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
expect(requestUrl.searchParams.get("current")).toContain("european_aqi_pm2_5");
expect(requestUrl.searchParams.get("hourly")).toContain("us_aqi_carbon_monoxide");
expect(requestUrl.searchParams.get("hourly")).toContain("ragweed_pollen");
});
it("rejects unsuccessful Open-Meteo responses", async () => {
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
await expect(
airQualityRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync(),
).rejects.toThrow();
});
});
+282
View File
@@ -0,0 +1,282 @@
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { z } from "zod";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
dayjs.extend(utc);
dayjs.extend(timezone);
const FORECAST_DAYS = 7;
const requestedVariables = [
"european_aqi",
"us_aqi",
"uv_index",
"pm10",
"pm2_5",
"carbon_monoxide",
"nitrogen_dioxide",
"sulphur_dioxide",
"ozone",
"european_aqi_pm2_5",
"european_aqi_pm10",
"european_aqi_nitrogen_dioxide",
"european_aqi_ozone",
"european_aqi_sulphur_dioxide",
"us_aqi_pm2_5",
"us_aqi_pm10",
"us_aqi_nitrogen_dioxide",
"us_aqi_carbon_monoxide",
"us_aqi_ozone",
"us_aqi_sulphur_dioxide",
"alder_pollen",
"birch_pollen",
"grass_pollen",
"mugwort_pollen",
"olive_pollen",
"ragweed_pollen",
] 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,
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());
url.searchParams.set("current", requestedVariables.join(","));
url.searchParams.set("hourly", requestedVariables.join(","));
url.searchParams.set("forecast_days", FORECAST_DAYS.toString());
url.searchParams.set("timezone", "auto");
const response = await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
if (!response.ok) throw new ResponseError(response);
const json: unknown = await response.json();
const airQuality = await atLocationOutput.parseAsync(json);
const hourly = airQuality.hourly.time.map((value, index) => {
return normalizePoint(airQuality.hourly, index, toUtcInstant(value, airQuality.timezone));
});
return {
timezone: airQuality.timezone,
utcOffsetSeconds: airQuality.utc_offset_seconds,
current: normalizePoint(
airQuality.current,
undefined,
toUtcInstant(airQuality.current.time, airQuality.timezone),
),
hourly,
daily: getDailyMaximums(airQuality.hourly),
} satisfies AirQuality;
},
});
const nullableNumber = z.number().nullable();
const nullableNumberArray = z.array(nullableNumber);
const currentValues = z.object({
time: z.string(),
european_aqi: nullableNumber,
us_aqi: nullableNumber,
uv_index: nullableNumber,
pm10: nullableNumber,
pm2_5: nullableNumber,
carbon_monoxide: nullableNumber,
nitrogen_dioxide: nullableNumber,
sulphur_dioxide: nullableNumber,
ozone: nullableNumber,
european_aqi_pm2_5: nullableNumber,
european_aqi_pm10: nullableNumber,
european_aqi_nitrogen_dioxide: nullableNumber,
european_aqi_ozone: nullableNumber,
european_aqi_sulphur_dioxide: nullableNumber,
us_aqi_pm2_5: nullableNumber,
us_aqi_pm10: nullableNumber,
us_aqi_nitrogen_dioxide: nullableNumber,
us_aqi_carbon_monoxide: nullableNumber,
us_aqi_ozone: nullableNumber,
us_aqi_sulphur_dioxide: nullableNumber,
alder_pollen: nullableNumber,
birch_pollen: nullableNumber,
grass_pollen: nullableNumber,
mugwort_pollen: nullableNumber,
olive_pollen: nullableNumber,
ragweed_pollen: nullableNumber,
});
const hourlyValues = z.object({
time: z.array(z.string()),
european_aqi: nullableNumberArray,
us_aqi: nullableNumberArray,
uv_index: nullableNumberArray,
pm10: nullableNumberArray,
pm2_5: nullableNumberArray,
carbon_monoxide: nullableNumberArray,
nitrogen_dioxide: nullableNumberArray,
sulphur_dioxide: nullableNumberArray,
ozone: nullableNumberArray,
european_aqi_pm2_5: nullableNumberArray,
european_aqi_pm10: nullableNumberArray,
european_aqi_nitrogen_dioxide: nullableNumberArray,
european_aqi_ozone: nullableNumberArray,
european_aqi_sulphur_dioxide: nullableNumberArray,
us_aqi_pm2_5: nullableNumberArray,
us_aqi_pm10: nullableNumberArray,
us_aqi_nitrogen_dioxide: nullableNumberArray,
us_aqi_carbon_monoxide: nullableNumberArray,
us_aqi_ozone: nullableNumberArray,
us_aqi_sulphur_dioxide: nullableNumberArray,
alder_pollen: nullableNumberArray,
birch_pollen: nullableNumberArray,
grass_pollen: nullableNumberArray,
mugwort_pollen: nullableNumberArray,
olive_pollen: nullableNumberArray,
ragweed_pollen: nullableNumberArray,
});
const atLocationOutput = z.object({
timezone: z.string().min(1),
utc_offset_seconds: z.number(),
current: currentValues,
hourly: hourlyValues,
});
type CurrentValues = z.infer<typeof currentValues>;
type HourlyValues = z.infer<typeof hourlyValues>;
const getValue = (values: CurrentValues | HourlyValues, field: keyof Omit<CurrentValues, "time">, index?: number) => {
const value = values[field];
if (Array.isArray(value)) return value[index ?? 0] ?? null;
return value;
};
const normalizePoint = (
values: CurrentValues | HourlyValues,
index: number | undefined,
observedAt: string,
): AirQualityPoint => ({
observedAt,
europeanAqi: getValue(values, "european_aqi", index),
usAqi: getValue(values, "us_aqi", index),
uvIndex: getValue(values, "uv_index", index),
pollutants: {
pm2_5: getValue(values, "pm2_5", index),
pm10: getValue(values, "pm10", index),
ozone: getValue(values, "ozone", index),
nitrogenDioxide: getValue(values, "nitrogen_dioxide", index),
sulphurDioxide: getValue(values, "sulphur_dioxide", index),
carbonMonoxide: getValue(values, "carbon_monoxide", index),
},
europeanSubIndices: {
pm2_5: getValue(values, "european_aqi_pm2_5", index),
pm10: getValue(values, "european_aqi_pm10", index),
ozone: getValue(values, "european_aqi_ozone", index),
nitrogenDioxide: getValue(values, "european_aqi_nitrogen_dioxide", index),
sulphurDioxide: getValue(values, "european_aqi_sulphur_dioxide", index),
carbonMonoxide: null,
},
usSubIndices: {
pm2_5: getValue(values, "us_aqi_pm2_5", index),
pm10: getValue(values, "us_aqi_pm10", index),
ozone: getValue(values, "us_aqi_ozone", index),
nitrogenDioxide: getValue(values, "us_aqi_nitrogen_dioxide", index),
sulphurDioxide: getValue(values, "us_aqi_sulphur_dioxide", index),
carbonMonoxide: getValue(values, "us_aqi_carbon_monoxide", index),
},
pollen: {
alder: getValue(values, "alder_pollen", index),
birch: getValue(values, "birch_pollen", index),
grass: getValue(values, "grass_pollen", index),
mugwort: getValue(values, "mugwort_pollen", index),
olive: getValue(values, "olive_pollen", index),
ragweed: getValue(values, "ragweed_pollen", index),
},
});
const getDailyMaximums = (hourly: HourlyValues): AirQuality["daily"] => {
const grouped = new Map<string, AirQuality["daily"][number]>();
hourly.time.forEach((time, index) => {
const date = time.slice(0, 10);
const existing = grouped.get(date) ?? {
date,
europeanAqiMax: null,
usAqiMax: null,
uvIndexMax: null,
pm2_5Max: null,
pm10Max: null,
};
existing.europeanAqiMax = maxNullable(existing.europeanAqiMax, hourly.european_aqi[index]);
existing.usAqiMax = maxNullable(existing.usAqiMax, hourly.us_aqi[index]);
existing.uvIndexMax = maxNullable(existing.uvIndexMax, hourly.uv_index[index]);
existing.pm2_5Max = maxNullable(existing.pm2_5Max, hourly.pm2_5[index]);
existing.pm10Max = maxNullable(existing.pm10Max, hourly.pm10[index]);
grouped.set(date, existing);
});
return Array.from(grouped.values()).slice(0, FORECAST_DAYS);
};
const maxNullable = (current: number | null, candidate: number | null | undefined): number | null => {
if (candidate === null || candidate === undefined) return current;
if (current === null) return candidate;
return Math.max(current, candidate);
};
const toUtcInstant = (value: string, timeZone: string): string => {
const parsed = dayjs.tz(value, timeZone);
if (!parsed.isValid()) throw new Error(`Invalid air quality timestamp: ${value}`);
return parsed.utc().toISOString();
};
export interface AirQualityPollutants {
pm2_5: number | null;
pm10: number | null;
ozone: number | null;
nitrogenDioxide: number | null;
sulphurDioxide: number | null;
carbonMonoxide: number | null;
}
export interface AirQualityPoint {
observedAt: string;
europeanAqi: number | null;
usAqi: number | null;
uvIndex: number | null;
pollutants: AirQualityPollutants;
europeanSubIndices: AirQualityPollutants;
usSubIndices: AirQualityPollutants;
pollen: {
alder: number | null;
birch: number | null;
grass: number | null;
mugwort: number | null;
olive: number | null;
ragweed: number | null;
};
}
export interface AirQuality {
timezone: string;
utcOffsetSeconds: number;
current: AirQualityPoint;
hourly: AirQualityPoint[];
daily: {
date: string;
europeanAqiMax: number | null;
usAqiMax: number | null;
uvIndexMax: number | null;
pm2_5Max: number | null;
pm10Max: number | null;
}[];
}
+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();
+17 -9
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,
@@ -13,6 +13,7 @@ import type {
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const logger = createLogger({ module: "beszelRequestHandler" });
const MEBIBYTE = 1024 ** 2;
export type { BeszelSystemRow } from "@homarr/integrations/types";
@@ -22,7 +23,7 @@ export type { BeszelSystemRow } from "@homarr/integrations/types";
*
* BeszelSystemInfo uses short field names (see beszel-types.ts for full docs):
* cpu=CPU%, mp=memory%, dp=disk%, g=GPU%, la=loadAvg, u=uptime(s), v=version,
* bb=bandwidth(bytes/s), b=bandwidth(Mbps legacy), dt=disk temp, bat=battery,
* bb=bandwidth(bytes/s), b=bandwidth(MiB/s legacy), dt=disk temp, bat=battery,
* sv=[running,total] services, h=hostname, m=CPU model, c=cores, ct=threads
*/
function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | null): BeszelSystemRow {
@@ -37,8 +38,8 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
extraFilesystems: info.efs ?? {},
gpu: info.g ?? 0,
loadAvg: info.la ?? null,
// bb = bytes/s (newer), b = Mbps (legacy, multiply to get bytes/s)
netBytes: info.bb ?? (info.b ?? 0) * 1_000_000,
// bb = bytes/s (newer), b = MiB/s (legacy, normalize to bytes/s)
netBytes: info.bb ?? (info.b ?? 0) * MEBIBYTE,
temp: info.dt ?? null,
battery: info.bat ?? null,
services: info.sv?.[0] ?? 0,
@@ -57,6 +58,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 +91,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 +126,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,
@@ -130,13 +134,17 @@ export const beszelStatsRequestHandler = createIntegrationRequestHandler<
const start = performance.now();
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
const instance = await createIntegrationAsync(integration);
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
const containerStats = input.includeDocker
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch((error) => {
const systemStatsPromise = instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
let containerStatsPromise: Promise<BeszelContainerStatsRecord[]> = Promise.resolve([]);
if (input.includeDocker) {
containerStatsPromise = instance
.getContainerStatsAsync(input.systemId, config.type, config.perPage)
.catch((error) => {
logger.warn("Failed to fetch Beszel container stats", { systemId: input.systemId, error: String(error) });
return [];
})
: [];
});
}
const [systemStats, containerStats] = await Promise.all([systemStatsPromise, containerStatsPromise]);
logger.debug("beszelStats fetch completed", {
integrationId: integration.id,
systemId: input.systemId,
@@ -0,0 +1,18 @@
// @vitest-environment node
import { describe, expect, test } from "vitest";
import { getCalendarMonthRange } from "./calendar";
describe("getCalendarMonthRange", () => {
test("treats month 5 as May and includes the calendar overflow days", () => {
const { startDate, endDate } = getCalendarMonthRange(2025, 5);
expect(startDate.getFullYear()).toBe(2025);
expect(startDate.getMonth()).toBe(3);
expect(startDate.getDate()).toBe(25);
expect(endDate.getFullYear()).toBe(2025);
expect(endDate.getMonth()).toBe(5);
expect(endDate.getDate()).toBe(6);
});
});
+12 -8
View File
@@ -1,26 +1,30 @@
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";
export const getCalendarMonthRange = (year: number, month: number) => {
const zeroBasedMonth = month - 1;
return {
startDate: dayjs().year(year).month(zeroBasedMonth).startOf("month").subtract(6, "days").toDate(),
endDate: dayjs().year(year).month(zeroBasedMonth).endOf("month").add(6, "days").toDate(),
};
};
export const calendarMonthRequestHandler = createIntegrationRequestHandler<
CalendarEvent[],
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.
const startDate = dayjs().year(input.year).month(input.month).startOf("month").subtract(6, "days");
const endDate = dayjs().year(input.year).month(input.month).endOf("month").add(6, "days");
const { startDate, endDate } = getCalendarMonthRange(input.year, input.month);
return await integrationInstance.getCalendarEventsAsync(
startDate.toDate(),
endDate.toDate(),
input.showUnmonitored,
);
return await integrationInstance.getCalendarEventsAsync(startDate, endDate, input.showUnmonitored);
},
});
+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();
+250 -50
View File
@@ -8,15 +8,40 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db, like, or } from "@homarr/db";
import { icons } from "@homarr/db/schema";
import { extractContainerImageName } from "@homarr/definitions";
import type { ContainerState, Port } from "@homarr/docker";
import type { ContainerState, DockerContainerTarget, DockerEndpointStatus, Port } from "@homarr/docker";
import { dockerLabels, DockerSingleton } from "@homarr/docker";
import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
const logger = createLogger({ module: "dockerRequestHandler" });
export const dockerWidgetEndpointTimeoutMs = 5_000;
const isDemoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
const withDockerTimeoutAsync = async <T>(
operation: (signal: AbortSignal) => Promise<T>,
message: string,
timeoutMs: number,
) => {
const controller = new AbortController();
const timeoutError = new Error(message);
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation(controller.signal),
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
controller.abort(timeoutError);
reject(timeoutError);
}, timeoutMs);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
}
};
const isDemoMode = () => ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
const port = (privatePort: number, publicPort: number, type: string): Port => ({
IP: "0.0.0.0",
@@ -170,40 +195,64 @@ const mockContainers: {
},
];
const demoDockerEndpoint = {
id: "demo",
name: "Demo Docker",
status: "available",
kind: "docker",
transport: "socket",
capabilities: ["inventory"],
source: "default",
scope: "admin",
} satisfies DockerEndpointStatus;
export const dockerContainersRequestHandler = createWidgetRequestHandler({
async requestAsync() {
if (isDemoMode) {
return mockContainers;
cacheNamespace: "docker-containers",
async requestAsync({ endpointIds }: { endpointIds?: string[] }) {
if (isDemoMode()) {
const includesDemoEndpoint =
endpointIds === undefined || endpointIds.length === 0 || endpointIds.includes("demo");
if (!includesDemoEndpoint) return { containers: [], endpoints: [] };
return {
containers: mockContainers.map((container) => ({
...container,
endpointId: "demo",
endpointName: "Demo Docker",
resourceId: `demo:${container.id}`,
})),
endpoints: [demoDockerEndpoint],
};
}
return await getContainersWithStatsAsync();
return await getContainersWithStatsAsync(dockerWidgetEndpointTimeoutMs, endpointIds);
},
});
const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image);
const findContainerByIdAsync = async (id: string) => {
const dockerInstances = DockerSingleton.getInstances();
const containers = await Promise.all(
dockerInstances.map(async ({ instance }) => {
const container = instance.getContainer(id);
export const hasDockerEndpointCapability = (
endpointId: string,
capability: "inventory" | "logs" | "lifecycle" | "remove",
) =>
isDemoMode()
? endpointId === "demo" && capability === "inventory"
: DockerSingleton.hasCapability(endpointId, capability);
return await new Promise<Container | null>((resolve) => {
container.inspect((err, data) => {
if (err || !data) {
resolve(null);
} else {
resolve(container);
}
});
});
}),
);
export const findDockerContainerAsync = async (
{ endpointId, id }: DockerContainerTarget,
requiredCapability: "inventory" | "logs" | "lifecycle" | "remove" = "inventory",
) => {
const dockerInstance = DockerSingleton.findInstance(endpointId);
if (!dockerInstance || !hasDockerEndpointCapability(endpointId, requiredCapability)) return null;
return containers.find((container) => container) ?? null;
const container = dockerInstance.instance.getContainer(id);
return await new Promise<Container | null>((resolve) => {
container.inspect((err, data) => resolve(err || !data ? null : container));
});
};
export const getContainerLogsAsync = async (id: string, tail = 200) => {
const container = await findContainerByIdAsync(id);
export const getContainerLogsAsync = async (target: DockerContainerTarget, tail = 200) => {
const container = await findDockerContainerAsync(target, "logs");
if (!container) {
return null;
}
@@ -219,12 +268,12 @@ export const getContainerLogsAsync = async (id: string, tail = 200) => {
};
export const streamContainerLogsAsync = async (
id: string,
target: DockerContainerTarget,
tail: number,
onData: (data: string) => void,
onError: (err: Error) => void,
) => {
const container = await findContainerByIdAsync(id);
const container = await findDockerContainerAsync(target, "logs");
if (!container) {
onError(new Error("Container not found"));
return () => undefined;
@@ -259,25 +308,126 @@ export const streamContainerLogsAsync = async (
};
};
async function getContainersWithStatsAsync() {
export const getDockerEndpointsAsync = (): DockerEndpointStatus[] => {
if (isDemoMode()) return [demoDockerEndpoint];
const dockerInstances = DockerSingleton.getInstances();
const initializationFailures = DockerSingleton.getInitializationFailures();
return [
...dockerInstances.map(({ endpointId, endpointName, descriptor }) => ({
id: endpointId,
name: endpointName,
status: "available" as const,
kind: descriptor.kind,
transport: descriptor.transport.type,
capabilities: descriptor.capabilities,
source: descriptor.source,
scope: descriptor.scope,
})),
...initializationFailures.map(({ descriptor }) => ({
id: descriptor.id,
name: descriptor.name,
status: "unavailable" as const,
kind: descriptor.kind,
transport: descriptor.transport.type,
capabilities: descriptor.capabilities,
source: descriptor.source,
scope: descriptor.scope,
})),
];
};
/** CPU counters suitable as a delta baseline for current-usage calculation. */
export type CpuUsageCounters = {
totalUsage: number;
systemUsage: number;
};
type CachedCpuUsageCounters = CpuUsageCounters & {
cachedAt: number;
};
// Docker's one-shot stats endpoint returns no usable precpu_stats baseline (the
// fields are present but zero), so current CPU usage cannot be derived from a
// single response. The request-handler process is long-lived and polls
// repeatedly, so the previous poll's counters serve as the baseline instead.
// Entries expire so removed containers do not accumulate.
const cachedCpuUsageCounters = new Map<string, CachedCpuUsageCounters>();
const cpuUsageCountersCacheTtlMs = 10 * 60 * 1000;
/** @internal Only intended for tests to isolate the process-local counter cache between cases. */
export function clearCpuUsageCountersCacheForTesting(): void {
cachedCpuUsageCounters.clear();
}
export async function getContainersWithStatsAsync(
timeoutMs = dockerWidgetEndpointTimeoutMs,
endpointIds: string[] = [],
) {
const cacheEvictedBefore = Date.now() - cpuUsageCountersCacheTtlMs;
for (const [key, counters] of cachedCpuUsageCounters) {
if (counters.cachedAt < cacheEvictedBefore) {
cachedCpuUsageCounters.delete(key);
}
}
const selectedEndpointIds = new Set(endpointIds);
const includesAllEndpoints = selectedEndpointIds.size === 0;
const dockerInstances = DockerSingleton.getInstances().filter(
({ endpointId }) => includesAllEndpoints || selectedEndpointIds.has(endpointId),
);
const initializationFailures = DockerSingleton.getInitializationFailures().filter(
({ descriptor }) => includesAllEndpoints || selectedEndpointIds.has(descriptor.id),
);
const results = await Promise.allSettled(
dockerInstances.map(async ({ instance, host }) => {
const instanceContainers = await instance.listContainers({ all: true });
dockerInstances.map(async ({ instance, host, endpointId, endpointName }) => {
const instanceContainers = await withDockerTimeoutAsync(
async (signal) => await instance.listContainers({ all: true, abortSignal: signal }),
`Timed out listing containers from Docker host ${host}`,
timeoutMs,
);
return instanceContainers
.filter((container) => !(dockerLabels.hide in container.Labels))
.map((container) => ({ ...container, instance: host }));
.map((container) => ({ ...container, instance: host, endpointId, endpointName }));
}),
);
const endpoints = [
...results.map((result, index) => {
const dockerInstance = dockerInstances.at(index);
if (!dockerInstance) throw new Error("Docker endpoint result did not match a configured endpoint");
return {
id: dockerInstance.endpointId,
name: dockerInstance.endpointName,
status: result.status === "fulfilled" ? ("available" as const) : ("unavailable" as const),
kind: dockerInstance.descriptor.kind,
transport: dockerInstance.descriptor.transport.type,
capabilities: dockerInstance.descriptor.capabilities,
source: dockerInstance.descriptor.source,
scope: dockerInstance.descriptor.scope,
} satisfies DockerEndpointStatus;
}),
...initializationFailures.map(({ descriptor }) => ({
id: descriptor.id,
name: descriptor.name,
status: "unavailable" as const,
kind: descriptor.kind,
transport: descriptor.transport.type,
capabilities: descriptor.capabilities,
source: descriptor.source,
scope: descriptor.scope,
})),
] satisfies DockerEndpointStatus[];
const containers = results.flatMap((result, index) => {
if (result.status === "fulfilled") return result.value;
const dockerInstance = dockerInstances.at(index);
logger.warn(
new ErrorWithMetadata(
"Failed to list containers from Docker host",
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
host: dockerInstances[index]!.host,
host: dockerInstance?.host ?? "unknown",
},
{ cause: result.reason },
),
@@ -294,26 +444,56 @@ async function getContainersWithStatsAsync() {
})
: [];
const degradedEndpointIds = new Set<string>();
const containerStatsPromises = containers.map(async (container) => {
const instance = dockerInstances.find(({ host }) => host === container.instance)?.instance;
const instance = DockerSingleton.findInstance(container.endpointId)?.instance;
if (!instance) return null;
const stats = await instance
.getContainer(container.Id)
.stats({ stream: false, "one-shot": true })
.catch(
() =>
({
cpu_stats: { online_cpus: 0, cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
memory_stats: { usage: 0 },
}) as ContainerStats,
let stats: ContainerStats;
try {
stats = await withDockerTimeoutAsync(
async (signal) => {
const options = { stream: false as const, "one-shot": true, abortSignal: signal };
return await instance.getContainer(container.Id).stats(options);
},
`Timed out reading Docker container stats for ${container.Id}`,
timeoutMs,
);
} catch (error) {
degradedEndpointIds.add(container.endpointId);
logger.warn(
new ErrorWithMetadata(
"Failed to read Docker container stats",
{ endpointId: container.endpointId, containerId: container.Id, host: container.instance },
{ cause: error },
),
);
stats = {
cpu_stats: { online_cpus: 0, cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
memory_stats: { usage: 0 },
} as ContainerStats;
}
const cpuUsageCacheKey = `${container.endpointId}:${container.Id}`;
const cpuUsage = calculateCpuUsage(stats, cachedCpuUsageCounters.get(cpuUsageCacheKey));
// Remember this poll's counters so the next one-shot response has a delta
// baseline even though the daemon does not provide precpu_stats.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const totalUsage = stats.cpu_stats?.cpu_usage?.total_usage;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const systemUsage = stats.cpu_stats?.system_cpu_usage;
if (typeof totalUsage === "number" && totalUsage > 0 && typeof systemUsage === "number" && systemUsage > 0) {
cachedCpuUsageCounters.set(cpuUsageCacheKey, { totalUsage, systemUsage, cachedAt: Date.now() });
}
const cpuUsage = calculateCpuUsage(stats);
const memoryUsage = calculateMemoryUsage(stats);
return {
id: container.Id,
endpointId: container.endpointId,
endpointName: container.endpointName,
resourceId: `${container.endpointId}:${container.Id}`,
name: container.Names[0]?.split("/")[1] ?? "Unknown",
host: container.instance,
state: container.State as ContainerState,
@@ -325,23 +505,43 @@ async function getContainersWithStatsAsync() {
};
});
return (await Promise.all(containerStatsPromises)).filter((container) => container !== null);
const resolvedContainers = (await Promise.all(containerStatsPromises)).filter((container) => container !== null);
return {
containers: resolvedContainers,
endpoints: endpoints.map((endpoint) =>
endpoint.status === "available" && degradedEndpointIds.has(endpoint.id)
? { ...endpoint, status: "degraded" as const }
: endpoint,
),
};
}
export function calculateCpuUsage(stats: ContainerStats): number {
export function calculateCpuUsage(stats: ContainerStats, previousCounters?: CpuUsageCounters): number {
// Handle containers with missing or invalid stats (e.g., exited, dead containers, Podman responses)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!stats.cpu_stats?.online_cpus || stats.cpu_stats.online_cpus === 0 || !stats.cpu_stats.cpu_usage?.total_usage) {
return 0;
}
const numberOfCpus = stats.cpu_stats.online_cpus;
const usage = stats.cpu_stats.system_cpu_usage;
if (!usage || usage === 0) {
// Docker's one-shot mode (and Podman) return precpu_stats with zeroed values,
// so a system counter of zero means there is no usable daemon-side baseline;
// fall back to the previous poll's counters, then to the cumulative totals.
const hasPrecpuBaseline = (stats.precpu_stats?.system_cpu_usage ?? 0) > 0;
const previousTotalUsage = hasPrecpuBaseline
? // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(stats.precpu_stats?.cpu_usage?.total_usage ?? 0)
: (previousCounters?.totalUsage ?? 0);
const previousSystemUsage = hasPrecpuBaseline
? (stats.precpu_stats?.system_cpu_usage ?? 0)
: (previousCounters?.systemUsage ?? 0);
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - previousTotalUsage;
const systemDelta = (stats.cpu_stats.system_cpu_usage ?? 0) - previousSystemUsage;
if (systemDelta <= 0 || cpuDelta < 0) {
return 0;
}
return (stats.cpu_stats.cpu_usage.total_usage / usage) * numberOfCpus * 100;
return (cpuDelta / systemDelta) * 100;
}
export function calculateMemoryUsage(stats: ContainerStats): number {
+3 -1
View File
@@ -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);
+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();
@@ -0,0 +1,50 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { immichAlbumsRequestHandler } from "./immich";
const mocks = vi.hoisted(() => ({
createIntegrationAsync: vi.fn(),
}));
vi.mock("@homarr/integrations/factory", () => ({
createIntegrationAsync: mocks.createIntegrationAsync,
}));
const integration = {
id: "immich-integration",
kind: "immich",
} as Parameters<typeof immichAlbumsRequestHandler.handler>[0];
const albums = [
{ id: "upstream-first", albumName: "Zebra", assetCount: 1 },
{ id: "second-b", albumName: "Beta", assetCount: 10 },
{ id: "second-a", albumName: "Alpha", assetCount: 10 },
];
describe("immichAlbumsRequestHandler", () => {
beforeEach(() => {
immichAlbumsRequestHandler.invalidateCache();
mocks.createIntegrationAsync.mockReset();
});
test("sorts deterministically before caching a limited response", async () => {
const getAlbumsAsync = vi.fn().mockResolvedValue([...albums]);
mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync });
const first = await immichAlbumsRequestHandler.handler(integration, { limit: 2 }).getDataAsync();
const cached = await immichAlbumsRequestHandler.handler(integration, { limit: 2 }).getDataAsync();
expect(first.data.map(({ id }) => id)).toStrictEqual(["second-a", "second-b"]);
expect(cached.data).toStrictEqual(first.data);
expect(getAlbumsAsync).toHaveBeenCalledOnce();
});
test("preserves upstream order when no limit is requested", async () => {
const getAlbumsAsync = vi.fn().mockResolvedValue([...albums]);
mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync });
const result = await immichAlbumsRequestHandler.handler(integration, {}).getDataAsync();
expect(result.data).toStrictEqual(albums);
});
});
+32 -4
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();
@@ -22,21 +23,48 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
assetCount: number;
}[],
IntegrationKindByCategory<"photoService">,
Record<string, never>
{ limit?: number }
>({
async requestAsync(integration) {
cacheNamespace: "immich:albums",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getAlbumsAsync();
const albums = await integrationInstance.getAlbumsAsync();
if (input.limit === undefined) return albums;
return albums
.toSorted(
(left, right) =>
right.assetCount - left.assetCount ||
compareText(left.albumName, right.albumName) ||
compareText(left.id, right.id),
)
.slice(0, input.limit);
},
});
const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
ImmichAlbum,
IntegrationKindByCategory<"photoService">,
{ albumId?: string }
>({
cacheNamespace: "immich:album",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getAlbumAsync(input.albumId);
},
});
export const immichAlbumPreviewRequestHandler = createIntegrationRequestHandler<
ImmichAlbum,
IntegrationKindByCategory<"photoService">,
{ albumId: string; randomizePhotos: boolean }
>({
cacheNamespace: "immich:album-preview",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getAlbumPreviewAsync(input.albumId, input.randomizePhotos);
},
});
@@ -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;
}
export 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,247 @@
import { createHash } from "node:crypto";
import { serialize } from "superjson";
import { isRecord } from "@homarr/common";
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 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,12 +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>({ requestAsync: options.requestAsync });
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,
};
};
+16
View File
@@ -0,0 +1,16 @@
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { LlamacppStats } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const llamacppStatsRequestHandler = createIntegrationRequestHandler<
LlamacppStats,
"llamacpp",
Record<string, never>
>({
cacheNamespace: "llamacpp:stats",
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatsAsync();
},
});
@@ -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();
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { MediaRequestStats } from "@homarr/integrations/types";
import { mediaRequestStatsRequestHandler } from "./media-request-stats";
const mocks = vi.hoisted(() => ({
createIntegrationAsync: vi.fn(),
}));
vi.mock("@homarr/integrations/factory", () => ({
createIntegrationAsync: mocks.createIntegrationAsync,
}));
const integration = {
id: "media-request-integration",
kind: "overseerr",
} as Parameters<typeof mediaRequestStatsRequestHandler.handler>[0];
describe("mediaRequestStatsRequestHandler", () => {
beforeEach(() => {
mediaRequestStatsRequestHandler.invalidateCache();
mocks.createIntegrationAsync.mockReset();
});
test("starts independent upstream requests together", async () => {
const requestsStarted = Promise.withResolvers<void>();
const expected = {
stats: {
total: 8,
movie: 5,
tv: 3,
pending: 1,
approved: 2,
declined: 1,
processing: 1,
available: 3,
},
users: [{ id: 1, displayName: "User", avatar: "/avatar.png", requestCount: 4, link: "/users/1" }],
} satisfies MediaRequestStats;
const getStatsAsync = vi.fn(async () => {
await requestsStarted.promise;
return expected.stats;
});
const getUsersAsync = vi.fn(async () => {
await requestsStarted.promise;
return expected.users;
});
mocks.createIntegrationAsync.mockResolvedValue({ getStatsAsync, getUsersAsync });
const resultPromise = mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
await vi.waitFor(() => {
expect(getStatsAsync).toHaveBeenCalledOnce();
expect(getUsersAsync).toHaveBeenCalledOnce();
});
requestsStarted.resolve();
await expect(resultPromise).resolves.toMatchObject({ data: expected });
});
});
@@ -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,11 +9,16 @@ 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([
integrationInstance.getStatsAsync(),
integrationInstance.getUsersAsync(),
]);
return {
stats: await integrationInstance.getStatsAsync(),
users: await integrationInstance.getUsersAsync(),
stats,
users,
};
},
cacheTtlMs: 60_000,
+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 });
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { mediaTranscodingRequestHandler } from "./media-transcoding";
const mocks = vi.hoisted(() => ({
createIntegrationAsync: vi.fn(),
}));
vi.mock("@homarr/integrations/factory", () => ({
createIntegrationAsync: mocks.createIntegrationAsync,
}));
const integration = {
id: "tdarr-integration",
kind: "tdarr",
} as Parameters<typeof mediaTranscodingRequestHandler.handler>[0];
describe("mediaTranscodingRequestHandler", () => {
beforeEach(() => {
mediaTranscodingRequestHandler.invalidateCache();
mocks.createIntegrationAsync.mockReset();
});
test("starts independent upstream requests together", async () => {
const requestsStarted = Promise.withResolvers<void>();
const getQueueAsync = vi.fn(async () => {
await requestsStarted.promise;
return { array: [], totalCount: 0, startIndex: 0, endIndex: 0 };
});
const getWorkersAsync = vi.fn(async () => {
await requestsStarted.promise;
return [];
});
const getStatisticsAsync = vi.fn(async () => {
await requestsStarted.promise;
return {};
});
mocks.createIntegrationAsync.mockResolvedValue({ getQueueAsync, getWorkersAsync, getStatisticsAsync });
const resultPromise = mediaTranscodingRequestHandler
.handler(integration, { pageOffset: 0, pageSize: 10 })
.getDataAsync();
await vi.waitFor(() => {
expect(getQueueAsync).toHaveBeenCalledOnce();
expect(getWorkersAsync).toHaveBeenCalledOnce();
expect(getStatisticsAsync).toHaveBeenCalledOnce();
});
requestsStarted.resolve();
await resultPromise;
});
});
@@ -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,12 +9,18 @@ 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([
integrationInstance.getQueueAsync(input.pageOffset, input.pageSize),
integrationInstance.getWorkersAsync(),
integrationInstance.getStatisticsAsync(),
]);
return {
queue: await integrationInstance.getQueueAsync(input.pageOffset, input.pageSize),
workers: await integrationInstance.getWorkersAsync(),
statistics: await integrationInstance.getStatisticsAsync(),
queue,
workers,
statistics,
};
},
});
@@ -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());
},
});
+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();
@@ -179,11 +179,30 @@ const getGithubReleaseAsync = async (
const api = getGithubApi(baseUrl, "Homarr-Lab/Homarr:GithubReleaseProvider", token);
try {
const allReleases = await api.paginate(api.rest.repos.listReleases, {
owner: parsed.owner,
repo: parsed.name,
per_page: 100,
});
const allReleases: Awaited<ReturnType<typeof api.rest.repos.listReleases>>["data"] = [];
try {
for await (const page of api.paginate.iterator(api.rest.repos.listReleases, {
owner: parsed.owner,
repo: parsed.name,
per_page: 100,
})) {
allReleases.push(...page.data);
}
} catch (error) {
// GitHub can reject a later page even though earlier pages contain the latest release.
if (
!(error instanceof OctokitRequestError) ||
error.status !== 422 ||
!error.message.includes("Only the first 1000 results are available") ||
allReleases.length === 0
) {
throw error;
}
logger.warn("GitHub release history capped; using the returned releases", {
identifier,
count: allReleases.length,
});
}
if (allReleases.length === 0) {
return { success: false, error: { code: "noReleasesFound", message: `${identifier} has no GitHub releases` } };
}
+37 -46
View File
@@ -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,
@@ -39,7 +44,8 @@ const attemptGetImageFromEntry = (feedUrl: string, entry: object) => {
};
const getImageFromStringAsFallback = (feedUrl: string, content: string) => {
const regex = /https?:\/\/\S+?\.(jpg|jpeg|png|gif|bmp|svg|webp|tiff)/i;
const regex =
/https?:\/\/[^\s"'<>\\]+?\.(jpg|jpeg|png|gif|bmp|svg|webp|tiff)\b(?:\?[^\s"'<>\\#]*)?(?:#[^\s"'<>\\]*)?/i;
const result = regex.exec(content);
if (result == null) {
@@ -52,50 +58,35 @@ const getImageFromStringAsFallback = (feedUrl: string, content: string) => {
return result[0];
};
const mediaProperties = [
{
path: ["enclosure", "@_url"],
},
{
path: ["media:content", "@_url"],
},
];
const mediaProperties = ["enclosure", "media:content", "media:thumbnail"] as const;
/**
* The RSS and Atom standards are poorly adhered to in most of the web.
* We want to show pretty background images on the posts and therefore need to extract
* the enclosure (aka. media images). This function uses the dynamic properties defined above
* to search through the possible paths and detect valid image URLs.
* the enclosure (aka. media images). This function checks known media fields in priority
* order and picks the first valid image URL from a field.
* @param feedObject The object to scan for.
* @returns the value of the first path that is found within the object
* @returns the first valid media URL, or null when none is found
*/
const getFirstMediaProperty = (feedObject: object) => {
for (const mediaProperty of mediaProperties) {
let propertyIndex = 0;
let objectAtPath: object = feedObject;
while (propertyIndex < mediaProperty.path.length) {
const key = mediaProperty.path[propertyIndex];
if (key === undefined) {
break;
}
const propertyEntries = Object.entries(objectAtPath);
const propertyEntry = propertyEntries.find(([entryKey]) => entryKey === key);
if (!propertyEntry) {
break;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const [_, propertyEntryValue] = propertyEntry;
objectAtPath = propertyEntryValue as object;
propertyIndex++;
}
const mediaValue: unknown = Object.entries(feedObject).find(([key]) => key === mediaProperty)?.[1];
const mediaEntries: unknown[] = Array.isArray(mediaValue) ? mediaValue : [mediaValue];
const validationResult = z.string().url().safeParse(objectAtPath);
if (!validationResult.success) {
continue;
}
for (const mediaEntry of mediaEntries) {
if (mediaEntry === null || typeof mediaEntry !== "object") {
continue;
}
logger.debug(`Found an image in the feed entry: ${validationResult.data}`);
return validationResult.data;
const url: unknown = Object.entries(mediaEntry).find(([key]) => key === "@_url")?.[1];
const validationResult = z.string().url().safeParse(url);
if (!validationResult.success) {
continue;
}
logger.debug(`Found an image in the feed entry: ${validationResult.data}`);
return validationResult.data;
}
}
return null;
};
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { toSafeEntityDetails } from "./smart-home-entity-state";
describe("toSafeEntityDetails", () => {
it("keeps display attributes and removes sensitive Home Assistant metadata", () => {
const details = toSafeEntityDetails({
entity_id: "camera.front_door",
state: "streaming",
last_changed: new Date("2026-01-01T00:00:00Z"),
last_updated: new Date("2026-01-01T00:00:01Z"),
attributes: {
friendly_name: "Front door",
icon: "mdi:camera",
access_token: "secret-camera-token",
latitude: 48.85,
},
});
expect(details.attributes).toEqual({ friendly_name: "Front door", icon: "mdi:camera" });
expect(details.attributes).not.toHaveProperty("access_token");
expect(details.attributes).not.toHaveProperty("latitude");
});
});
@@ -1,13 +1,33 @@
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";
const safeAttributeKeys = ["friendly_name", "unit_of_measurement", "device_class", "icon"] as const;
export const toSafeEntityDetails = (entity: EntityState) => {
const attributes: Partial<Record<(typeof safeAttributeKeys)[number], string>> = {};
for (const key of safeAttributeKeys) {
const value = entity.attributes[key];
if (typeof value === "string") attributes[key] = value;
}
return {
attributes,
entity_id: entity.entity_id,
last_changed: entity.last_changed,
last_updated: entity.last_updated,
state: entity.state,
};
};
export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandler<
string,
EntityState,
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);
@@ -16,7 +36,7 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
throw new Error(`Unable to fetch data from Home Assistant error='${result.error as string}'`);
}
return result.data.state;
return result.data;
},
// The widget already polls every 30 seconds and refetches after toggles.
cacheTtlMs: 0,
@@ -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();
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, test } from "vitest";
import { getDemoStatsValues, getStatsSnapshotAsync } from "./stats";
const previousDemoMode = process.env.DEMO_MODE;
afterEach(() => {
if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
else process.env.DEMO_MODE = previousDemoMode;
});
describe("demo integration statistics", () => {
test("returns showcase values only for seeded demo sources", () => {
process.env.DEMO_MODE = "true";
expect(getDemoStatsValues({ kind: "sonarr", url: "https://demo.homarr.dev" })).toMatchObject({
shows: 74,
episodes: 3_986,
});
expect(getDemoStatsValues({ kind: "mealie", url: "https://demo.homarr.dev" })).toEqual({
recipes: 318,
users: 14,
categories: 28,
tags: 76,
});
expect(getDemoStatsValues({ kind: "spoolman", url: "https://demo.homarr.dev" })).toEqual({
spools: 24,
remainingWeight: 8_120,
});
expect(getDemoStatsValues({ kind: "sonarr", url: "https://sonarr.example.com" })).toBeUndefined();
expect(getDemoStatsValues({ kind: "mock", url: "https://demo.homarr.dev" })).toBeUndefined();
});
test("serves seeded values through the read-only snapshot path", async () => {
process.env.DEMO_MODE = "true";
const snapshot = await getStatsSnapshotAsync({
id: "demo-spoolman",
appId: null,
kind: "spoolman",
name: "Spoolman",
url: "https://demo.homarr.dev",
externalUrl: null,
decryptedSecrets: [],
});
expect(snapshot).toMatchObject({
values: { spools: 24, remainingWeight: 8_120 },
retryAt: 0,
error: false,
stale: false,
});
expect(snapshot.updatedAt).not.toBeNull();
});
test("does not replace live statistics outside demo mode", () => {
process.env.DEMO_MODE = "false";
expect(getDemoStatsValues({ kind: "sonarr", url: "https://demo.homarr.dev" })).toBeUndefined();
});
});
+219
View File
@@ -0,0 +1,219 @@
import { z } from "zod/v4";
import type { IntegrationKind } from "@homarr/definitions";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { fetchStatsAsync, getStatsMetrics } from "@homarr/integrations/stats";
import type { StatsValue } from "@homarr/integrations/stats";
import { createGetSetChannel, createLockChannel, getIntegrationCacheGenerationAsync } from "@homarr/redis";
import { getIntegrationCacheIdentity } from "./lib/integration-request-handler";
const logger = createLogger({ module: "integrationStats" });
const FRESH_MS = 60 * 60 * 1000;
const RETRY_MS = 60_000;
const MAX_PENDING = 128;
const snapshotSchema = z.object({
identity: z.string(),
values: z.record(z.string(), z.union([z.number().finite(), z.string().max(4096), z.boolean(), z.null()])),
updatedAt: z.number().nullable(),
retryAt: z.number(),
error: z.boolean(),
});
type Snapshot = z.infer<typeof snapshotSchema>;
type Input = Parameters<typeof getIntegrationCacheIdentity>[0]["integration"] & { kind: IntegrationKind };
const running = new Map<string, Promise<void>>();
const cacheOptions = { useBoundedCacheClient: true };
const demoValues: Partial<Record<IntegrationKind, Record<string, StatsValue>>> = {
sonarr: {
shows: 74,
monitored: 68,
downloaded: 3_128,
storage: 1.82 * 1024 ** 4,
missing: 6,
queued: 3,
episodes: 3_986,
},
radarr: {
movies: 386,
monitored: 342,
downloaded: 371,
storage: 3.74 * 1024 ** 4,
missing: 15,
queued: 4,
},
qBittorrent: { download: 38 * 1024 ** 2, upload: 9.3 * 1024 ** 2, paused: false },
proxmox: { nodes: 3, vms: 12, lxcs: 8 },
piHole: {
dnsQueriesToday: 119_872,
adsBlockedToday: 19_641,
adsBlockedTodayPercentage: 16.38,
domainsBeingBlocked: 1_186_431,
},
immich: {
userCount: 4,
photoCount: 48_392,
videoCount: 2_438,
totalLibraryUsageInBytes: 684.5 * 1024 ** 3,
},
karakeep: { bookmarks: 1_247, favorites: 86, archived: 212, highlights: 418, lists: 24, tags: 93 },
mealie: { recipes: 318, users: 14, categories: 28, tags: 76 },
spoolman: { spools: 24, remainingWeight: 8_120 },
};
export const getDemoStatsValues = (integration: Pick<Input, "kind" | "url">) => {
const demoMode = ["1", "yes", "t", "true"].includes((process.env.DEMO_MODE ?? "").toLowerCase());
if (!demoMode || integration.url !== "https://demo.homarr.dev") return undefined;
return demoValues[integration.kind];
};
const resolveAsync = async (integration: Input) => {
const generation = await getIntegrationCacheGenerationAsync(integration.id);
if (!generation.isShared) throw new Error("Statistics cache is temporarily unavailable");
// Snapshots outlive the expiring response-cache generation. Credentials and URLs
// remain part of their identity; generations only guard in-flight refreshes.
const identity = getIntegrationCacheIdentity({ integration, options: { version: 1 } });
// A stable key replaces old credentials' data instead of retaining unreachable snapshots forever.
const name = `integration-stats:snapshot:v1:${integration.id}`;
const channel = createGetSetChannel<Snapshot>(name, cacheOptions);
const parsed = snapshotSchema.safeParse(await channel.getAsync());
let snapshot: Snapshot = { identity, values: {}, updatedAt: null, retryAt: 0, error: false };
// Retain snapshots written before response generations were decoupled.
if (parsed.success && (parsed.data.identity === identity || parsed.data.identity.endsWith(`:${identity}`)))
snapshot = { ...parsed.data, identity };
return { identity, generation: generation.value, name, channel, snapshot };
};
export const getStatsSnapshotAsync = async (integration: Input) => {
const demo = getDemoStatsValues(integration);
if (demo) {
return {
values: demo,
updatedAt: Date.now(),
retryAt: 0,
error: false,
stale: false,
};
}
const { snapshot } = await resolveAsync(integration);
return {
values: snapshot.values,
updatedAt: snapshot.updatedAt,
retryAt: snapshot.retryAt,
error: snapshot.error,
stale: snapshot.updatedAt === null || Date.now() - snapshot.updatedAt >= FRESH_MS,
};
};
const pauseAsync = () => new Promise<void>((resolve) => setTimeout(resolve, 250));
export const refreshStatsAsync = async (integration: Input, force: boolean) => {
const key = integration.id;
const pending = running.get(key);
if (pending) return await pending;
if (running.size >= MAX_PENDING) throw new Error("Statistics refresh queue is full");
const work = runRefreshAsync(integration, force).finally(() => running.delete(key));
running.set(key, work);
return await work;
};
const runRefreshAsync = async (integration: Input, force: boolean) => {
const initial = await resolveAsync(integration);
if (!force && initial.snapshot.retryAt > Date.now()) return;
if (!force && initial.snapshot.updatedAt !== null && Date.now() - initial.snapshot.updatedAt < FRESH_MS) return;
const lock = createLockChannel(`integration-stats:lock:${integration.id}`, cacheOptions);
const token = await lock.acquireAsync(120);
// Another process owns this source. The caller keeps its snapshot and checks again later.
if (!token) return;
let slot: ReturnType<typeof createLockChannel> | undefined;
let slotToken: string | undefined;
let owned = true;
const controller = new AbortController();
const renewal = setInterval(() => {
void (async () => {
if (!(await lock.renewAsync(token, 120))) owned = false;
if (slot && slotToken && !(await slot.renewAsync(slotToken, 120))) owned = false;
if (!owned) controller.abort();
})().catch(() => {
owned = false;
controller.abort();
});
}, 10_000);
renewal.unref?.();
let deadline: ReturnType<typeof setTimeout> | undefined;
try {
const waitUntil = Date.now() + 60_000;
while (!slot && Date.now() < waitUntil) {
if (!owned) break;
for (let index = 0; index < 4; index += 1) {
const candidate = createLockChannel(`integration-stats:slot:${index}`, cacheOptions);
const acquired = await candidate.acquireAsync(120);
if (!acquired) continue;
slot = candidate;
slotToken = acquired;
break;
}
if (!slot) await pauseAsync();
}
if (!slot || !owned) return;
// Recheck after queuing: credentials or another refresh may have changed the snapshot.
const current = await resolveAsync(integration);
if (current.identity !== initial.identity || current.generation !== initial.generation) return;
if (!force && current.snapshot.updatedAt !== null && Date.now() - current.snapshot.updatedAt < FRESH_MS) return;
deadline = setTimeout(() => controller.abort(), 60_000);
deadline.unref?.();
try {
const raw = getDemoStatsValues(integration) ?? (await fetchBeforeAbortAsync(integration, controller.signal));
controller.signal.throwIfAborted();
const values: Snapshot["values"] = {};
for (const metric of getStatsMetrics(integration.kind)) values[metric.key] = raw[metric.key] ?? null;
const snapshot = snapshotSchema.parse({
identity: initial.identity,
values,
updatedAt: Date.now(),
retryAt: 0,
error: false,
});
const latest = await resolveAsync(integration);
if (owned && latest.identity === initial.identity && latest.generation === initial.generation)
await lock.setPersistentIfOwnedAsync(token, initial.name, snapshot);
} catch {
// Promise.all can reject before sibling provider requests finish.
controller.abort();
logger.warn("Statistics source refresh failed", { integrationId: integration.id, kind: integration.kind });
const latest = await resolveAsync(integration);
if (owned && latest.identity === initial.identity && latest.generation === initial.generation) {
await lock.setPersistentIfOwnedAsync(token, initial.name, {
...current.snapshot,
retryAt: Date.now() + RETRY_MS,
error: true,
});
}
}
} finally {
controller.abort();
clearInterval(renewal);
if (deadline) clearTimeout(deadline);
const releases = [lock.releaseAsync(token)];
if (slot && slotToken) releases.push(slot.releaseAsync(slotToken));
await Promise.allSettled(releases);
}
};
// Legacy adapters may ignore cancellation. Stop waiting so one stalled source cannot
// retain a distributed refresh slot forever; their own in-flight guard prevents overlap.
const fetchBeforeAbortAsync = async (integration: Input, signal: AbortSignal) => {
signal.throwIfAborted();
let onAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
onAbort = () => reject(signal.reason);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
return await Promise.race([fetchStatsAsync(integration, signal), aborted]);
} finally {
if (onAbort) signal.removeEventListener("abort", onAbort);
}
};
+7 -8
View File
@@ -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) {
@@ -1,9 +1,18 @@
import type { ContainerStats } from "dockerode";
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { DockerSingleton } from "@homarr/docker";
import { calculateCpuUsage, calculateMemoryUsage } from "../docker";
import {
calculateCpuUsage,
calculateMemoryUsage,
clearCpuUsageCountersCacheForTesting,
dockerContainersRequestHandler,
getDockerEndpointsAsync,
getContainersWithStatsAsync,
hasDockerEndpointCapability,
} from "../docker";
vi.mock("@homarr/db", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -64,16 +73,42 @@ describe("calculateCpuUsage", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 2000 }, system_cpu_usage: 10000 },
});
// (2000 / 10000) * 4 * 100 = 80
expect(calculateCpuUsage(stats)).toBe(80);
// (2000 / 10000) * 100 = 20
expect(calculateCpuUsage(stats)).toBe(20);
});
test("should handle fractional CPU usage", () => {
const stats = createStats({
cpu_stats: { online_cpus: 2, cpu_usage: { total_usage: 500 }, system_cpu_usage: 100000 },
});
// (500 / 100000) * 2 * 100 = 1
expect(calculateCpuUsage(stats)).toBe(1);
// (500 / 100000) * 100 = 0.5
expect(calculateCpuUsage(stats)).toBe(0.5);
});
test("should use previous poll counters when precpu_stats is zeroed (one-shot mode)", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 3_000 }, system_cpu_usage: 20_000 },
// Docker's one-shot response contains precpu_stats with all-zero values
precpu_stats: { cpu_usage: { total_usage: 0 }, system_cpu_usage: 0 },
});
// Delta vs previous poll: cpu 3_000 - 1_000 = 2_000, system 20_000 - 10_000 = 10_000 → 20%
expect(calculateCpuUsage(stats, { totalUsage: 1_000, systemUsage: 10_000 })).toBe(20);
});
test("should prefer a valid precpu_stats baseline over previous poll counters", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 3_000 }, system_cpu_usage: 20_000 },
precpu_stats: { cpu_usage: { total_usage: 2_500 }, system_cpu_usage: 15_000 },
});
// precpu deltas: cpu 500, system 5_000 → 10%; the previous counters must be ignored
expect(calculateCpuUsage(stats, { totalUsage: 1_000, systemUsage: 10_000 })).toBe(10);
});
test("should return 0 when current counters are lower than the previous poll (container restart)", () => {
const stats = createStats({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: 100 }, system_cpu_usage: 20_000 },
});
expect(calculateCpuUsage(stats, { totalUsage: 1_000, systemUsage: 10_000 })).toBe(0);
});
});
@@ -119,3 +154,194 @@ describe("calculateMemoryUsage", () => {
expect(calculateMemoryUsage(stats)).toBe(512);
});
});
describe("getContainersWithStatsAsync", () => {
beforeEach(() => {
clearCpuUsageCountersCacheForTesting();
});
test("computes current CPU usage from successive one-shot polls", async () => {
let totalUsage = 1_000_000;
let systemUsage = 10_000_000;
const dockerInstance = createDockerInstance("local", async () => [
{
Id: "busy",
Image: "immich:latest",
Labels: {},
Names: ["/immich"],
State: "running",
Ports: [],
},
]);
dockerInstance.instance.getContainer = (() => ({
stats: async () => ({
cpu_stats: { online_cpus: 4, cpu_usage: { total_usage: totalUsage }, system_cpu_usage: systemUsage },
memory_stats: { usage: 0 },
}),
})) as never;
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([dockerInstance] as never);
vi.spyOn(DockerSingleton, "findInstance").mockReturnValue(dockerInstance as never);
try {
// First poll has no baseline: falls back to the cumulative ratio (10%)
const first = await getContainersWithStatsAsync(50);
expect(first.containers).toEqual([expect.objectContaining({ id: "busy", cpuUsage: 10 })]);
// Next poll used 2 of 4 core-seconds → current usage 50%, not a lifetime average
totalUsage += 2_000_000;
systemUsage += 4_000_000;
const second = await getContainersWithStatsAsync(50);
expect(second.containers).toEqual([expect.objectContaining({ id: "busy", cpuUsage: 50 })]);
} finally {
vi.restoreAllMocks();
}
});
test("queries only selected Docker endpoints and leaves an empty selection as all", async () => {
const firstListContainers = vi.fn(async () => []);
const secondListContainers = vi.fn(async () => []);
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([
createDockerInstance("first", firstListContainers),
createDockerInstance("second", secondListContainers),
] as never);
vi.spyOn(DockerSingleton, "getInitializationFailures").mockReturnValue([]);
try {
const selectedResult = await getContainersWithStatsAsync(50, ["second"]);
expect(firstListContainers).not.toHaveBeenCalled();
expect(secondListContainers).toHaveBeenCalledOnce();
expect(selectedResult.endpoints.map(({ id }) => id)).toEqual(["second"]);
await getContainersWithStatsAsync(50, []);
expect(firstListContainers).toHaveBeenCalledOnce();
expect(secondListContainers).toHaveBeenCalledTimes(2);
} finally {
vi.restoreAllMocks();
}
});
test("marks a timed-out endpoint unavailable without blocking healthy endpoints", async () => {
vi.useFakeTimers();
try {
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([
createDockerInstance("stalled", () => new Promise(() => undefined)),
createDockerInstance("healthy", async () => []),
] as never);
const resultPromise = getContainersWithStatsAsync(50);
await vi.advanceTimersByTimeAsync(50);
const result = await resultPromise;
expect(result.endpoints).toEqual([
expect.objectContaining({ id: "stalled", status: "unavailable" }),
expect.objectContaining({ id: "healthy", status: "available" }),
]);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
});
test("marks an endpoint degraded and returns zeroed stats when its stats request times out", async () => {
vi.useFakeTimers();
try {
const dockerInstance = createDockerInstance("healthy", async () => [
{
Id: "container-1",
Image: "sonarr:latest",
Labels: {},
Names: ["/sonarr"],
State: "running",
Ports: [],
},
]);
let observedSignal: AbortSignal | undefined;
dockerInstance.instance.getContainer = (() => ({
stats: (options: { abortSignal?: AbortSignal }) => {
observedSignal = options.abortSignal;
return new Promise(() => undefined);
},
})) as never;
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([dockerInstance] as never);
vi.spyOn(DockerSingleton, "findInstance").mockReturnValue(dockerInstance as never);
const resultPromise = getContainersWithStatsAsync(50);
await vi.advanceTimersByTimeAsync(50);
const result = await resultPromise;
expect(observedSignal?.aborted).toBe(true);
expect(result.containers).toEqual([expect.objectContaining({ id: "container-1", cpuUsage: 0, memoryUsage: 0 })]);
expect(result.endpoints).toEqual([expect.objectContaining({ id: "healthy", status: "degraded" })]);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
});
test("includes endpoints that failed during TLS initialization", async () => {
vi.spyOn(DockerSingleton, "getInstances").mockReturnValue([]);
vi.spyOn(DockerSingleton, "getInitializationFailures").mockReturnValue([
{
host: "broken.example:2376",
descriptor: {
id: "broken-tls",
name: "Broken TLS",
kind: "docker",
transport: { type: "tls", host: "broken.example", port: 2376, caPath: "/missing/ca.pem" },
capabilities: ["inventory"],
scope: "admin",
source: "environment",
},
},
]);
const result = await getContainersWithStatsAsync(50);
expect(result.endpoints).toEqual([
expect.objectContaining({ id: "broken-tls", status: "unavailable", transport: "tls" }),
]);
vi.restoreAllMocks();
});
});
test("demo mode advertises and filters the same inventory-only endpoint", async () => {
const previousDemoMode = process.env.DEMO_MODE;
process.env.DEMO_MODE = "true";
try {
expect(getDockerEndpointsAsync()).toEqual([
expect.objectContaining({ id: "demo", name: "Demo Docker", capabilities: ["inventory"] }),
]);
const selected = await dockerContainersRequestHandler.handler({ endpointIds: ["demo"] }).getDataAsync();
expect(selected.data.endpoints).toEqual([expect.objectContaining({ id: "demo" })]);
expect(selected.data.containers.length).toBeGreaterThan(0);
const excluded = await dockerContainersRequestHandler.handler({ endpointIds: ["other"] }).getDataAsync();
expect(excluded.data).toEqual({ containers: [], endpoints: [] });
expect(hasDockerEndpointCapability("demo", "inventory")).toBe(true);
expect(hasDockerEndpointCapability("demo", "logs")).toBe(false);
expect(hasDockerEndpointCapability("other", "inventory")).toBe(false);
} finally {
dockerContainersRequestHandler.invalidateCache();
if (previousDemoMode === undefined) delete process.env.DEMO_MODE;
else process.env.DEMO_MODE = previousDemoMode;
}
});
const createDockerInstance = (endpointId: string, listContainers: () => Promise<unknown[]>) => ({
endpointId,
endpointName: endpointId,
host: endpointId,
descriptor: {
kind: "docker",
transport: { type: "socket" },
capabilities: ["inventory"],
source: "environment",
scope: "admin",
},
instance: { listContainers, getContainer: () => ({}) },
});
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { Response } from "undici";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { getLatestMatchingReleaseAsync } from "../release-providers";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => ({ warn: vi.fn() }) }));
const mockedFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const limitMessage = "Only the first 1000 results are available";
const mockHistoryFailure = (failurePage: number, status: number, message: string) => {
const pages: number[] = [];
mockedFetch.mockImplementation(async (input) => {
let requestUrl = input.toString();
if (typeof input === "object" && "url" in input) requestUrl = String(input.url);
const url = new URL(requestUrl);
if (!url.pathname.endsWith("/releases")) {
return new Response(JSON.stringify({ created_at: "2020-01-01T00:00:00Z" }), {
headers: { "content-type": "application/json" },
});
}
const page = Number(url.searchParams.get("page") ?? 1);
pages.push(page);
if (page === failurePage) {
return new Response(JSON.stringify({ message }), { status, headers: { "content-type": "application/json" } });
}
const releases = Array.from({ length: 100 }, (_, index) => {
const offset = (page - 1) * 100 + index;
return {
tag_name: `v${1001 - offset}`,
published_at: new Date(Date.UTC(2026, 0, 1) - offset * 1000).toISOString(),
html_url: "https://example.com/release",
body: "",
prerelease: false,
};
});
url.searchParams.set("page", String(page + 1));
return new Response(JSON.stringify(releases), {
headers: { "content-type": "application/json", link: `<${url}>; rel="next"` },
});
});
return pages;
};
const request = (versionRegex?: string) =>
getLatestMatchingReleaseAsync({ id: "fixture", provider: "github", identifier: "owner/repo", versionRegex });
describe("GitHub release history limits", () => {
beforeEach(() => {
mockedFetch.mockReset();
});
test.each([
{ versionRegex: undefined, expected: "v1001" },
{ versionRegex: "^v2$", expected: "v2" },
])(
"retains matching release $expected when the next page reaches GitHub's cap",
async ({ versionRegex, expected }) => {
const pages = mockHistoryFailure(11, 422, limitMessage);
const result = await request(versionRegex);
expect(result.success).toBe(true);
if (!result.success) throw new Error(result.error.message);
expect(result.data.latestRelease).toBe(expected);
expect(pages).toEqual(Array.from({ length: 11 }, (_, index) => index + 1));
},
);
test.each([
{ page: 2, status: 422, message: "Validation Failed" },
{ page: 2, status: 401, message: "Bad credentials" },
{ page: 1, status: 422, message: limitMessage },
])("does not hide an API error on page $page: $message", async ({ page, status, message }) => {
mockHistoryFailure(page, status, message);
const result = await request();
expect(result.success).toBe(false);
if (result.success) throw new Error("Expected API failure");
expect(result.error.code).toBe("unexpected");
expect(result.error.message).toContain(message);
});
});
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
createIntegrationAsync: vi.fn(),
}));
vi.mock("@homarr/integrations", () => ({
vi.mock("@homarr/integrations/factory", () => ({
createIntegrationAsync: mocks.createIntegrationAsync,
}));
@@ -0,0 +1,26 @@
import type { Response } from "undici";
import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
export const DEFAULT_TIMETABLE_BASE_URL = "https://search.ch";
export const MAX_TIMETABLE_RESPONSE_BYTES = 512 * 1024;
export const normalizeTimetableBaseUrl = (baseUrl: string) => {
if (baseUrl.length > 2_048) throw new Error("Timetable base URL is too long");
const url = new URL(baseUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("Timetable base URL must use HTTP or HTTPS");
}
if (url.username || url.password) throw new Error("Timetable base URL must not contain credentials");
if (url.search || url.hash) throw new Error("Timetable base URL must not contain a query or fragment");
return url.toString().replace(/\/$/, "");
};
export async function readBoundedTimetableJsonAsync(
response: Response,
maxBytes = MAX_TIMETABLE_RESPONSE_BYTES,
): Promise<unknown> {
return await readBoundedJsonResponse(response, maxBytes, "Timetable response");
}
@@ -0,0 +1,216 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler } from "./timetable";
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
vi.mock("@homarr/core/infrastructure/http", () => ({
createCertificateAgentAsync: vi.fn(),
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const mockCreateCertificateAgent = vi.mocked(createCertificateAgentAsync);
const mockCloseDispatcher = vi.fn();
const mockDestroyDispatcher = vi.fn().mockResolvedValue(undefined);
const mockDispatcher = { close: mockCloseDispatcher, destroy: mockDestroyDispatcher };
beforeEach(() => {
vi.clearAllMocks();
mockCreateCertificateAgent.mockResolvedValue(mockDispatcher as never);
});
describe("normalizeTimetableBaseUrl", () => {
it("normalizes an HTTP endpoint", () => {
expect(normalizeTimetableBaseUrl("https://search.ch/")).toBe("https://search.ch");
});
it.each(["file:///etc/passwd", "https://user:secret@example.com", "https://example.com?redirect=/admin"])(
"rejects unsafe base URL %s",
(url) => expect(() => normalizeTimetableBaseUrl(url)).toThrow(),
);
});
describe("timetable response bounds", () => {
it("rejects a response once its byte budget is exceeded", async () => {
const response = new Response(JSON.stringify({ value: "x".repeat(100) }));
await expect(readBoundedTimetableJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes");
});
it("caps and caches station results and rejects redirects", async () => {
mockFetch.mockResolvedValue(
new Response(
JSON.stringify(
Array.from({ length: 125 }, (_, index) => ({
id: String(index),
label: `Station ${index}`,
iconclass: "train",
})),
),
),
);
const input = { baseUrl: "http://timetable.internal", query: "bounded-stations" };
const first = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
const second = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
expect(first.data).toHaveLength(100);
expect(second.data).toHaveLength(100);
expect(mockFetch).toHaveBeenCalledOnce();
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ redirect: "error", bodyTimeout: 10_000 }),
);
});
it("pins the vetted address into a direct connection without changing the request hostname", async () => {
const pinnedAddresses = [
{ address: "93.184.216.34", family: 4 as const },
{ address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 as const },
];
mockFetch.mockImplementation(async () => {
const pinnedLookup = mockCreateCertificateAgent.mock.calls[0]?.[0]?.lookup;
expect(pinnedLookup).toBeTypeOf("function");
if (!pinnedLookup) throw new Error("Expected a pinned lookup function");
const lookupCallback = vi.fn();
pinnedLookup("timetable.example.com", { all: true }, lookupCallback);
expect(lookupCallback).toHaveBeenCalledWith(null, pinnedAddresses);
const repeatedLookupCallback = vi.fn();
pinnedLookup("timetable.example.com", { all: true }, repeatedLookupCallback);
expect(repeatedLookupCallback).toHaveBeenCalledWith(null, pinnedAddresses);
expect(mockCloseDispatcher).not.toHaveBeenCalled();
const unexpectedHostCallback = vi.fn();
pinnedLookup("different.example.com", { all: true }, unexpectedHostCallback);
expect(unexpectedHostCallback.mock.calls[0]?.[0]).toMatchObject({ code: "ENOTFOUND" });
return new Response(JSON.stringify([{ id: "station", label: "Station", iconclass: "train" }]));
});
await timetableSearchStationsRequestHandler
.handler({
baseUrl: "https://timetable.example.com",
query: "pinned-stations",
pinnedAddresses,
})
.getDataAsync();
expect(mockCreateCertificateAgent).toHaveBeenCalledWith(
{ lookup: expect.any(Function) },
{
autoSelectFamily: true,
bodyTimeout: 10_000,
httpProxy: "",
httpsProxy: "",
noProxy: "*",
},
);
const requestUrl = mockFetch.mock.calls[0]?.[0];
expect(new URL(String(requestUrl)).hostname).toBe("timetable.example.com");
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ dispatcher: mockDispatcher, redirect: "error" }),
);
expect(mockCloseDispatcher).toHaveBeenCalledOnce();
});
it("pins timetable requests and closes their direct dispatcher", async () => {
mockFetch.mockResolvedValue(
new Response(
JSON.stringify({
connections: [
{
time: "2026-01-01T00:00:00Z",
line: "1",
color: "ffffff",
terminal: { name: "Stop" },
dep_delay: "0",
},
],
}),
),
);
const pinnedAddresses = [{ address: "93.184.216.34", family: 4 as const }];
await timetableGetTimetableRequestHandler
.handler({
baseUrl: "https://timetable.example.com",
stationId: "pinned-departures",
limit: 1,
pinnedAddresses,
})
.getDataAsync();
expect(mockCreateCertificateAgent).toHaveBeenCalledWith(
{ lookup: expect.any(Function) },
expect.objectContaining({ noProxy: "*" }),
);
expect(mockFetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ dispatcher: mockDispatcher, redirect: "error" }),
);
expect(mockCloseDispatcher).toHaveBeenCalledOnce();
});
it("destroys the pinned station dispatcher when response handling fails", async () => {
mockFetch.mockResolvedValue(new Response("not json"));
await expect(
timetableSearchStationsRequestHandler
.handler({
baseUrl: "https://timetable.example.com",
query: "invalid-stations",
pinnedAddresses: [{ address: "93.184.216.34", family: 4 }],
})
.getDataAsync(),
).rejects.toThrow();
expect(mockDestroyDispatcher).toHaveBeenCalledOnce();
expect(mockCloseDispatcher).not.toHaveBeenCalled();
});
it("destroys the pinned timetable dispatcher when response handling fails", async () => {
mockFetch.mockResolvedValue(new Response("not json"));
await expect(
timetableGetTimetableRequestHandler
.handler({
baseUrl: "https://timetable.example.com",
stationId: "invalid-departures",
limit: 1,
pinnedAddresses: [{ address: "93.184.216.34", family: 4 }],
})
.getDataAsync(),
).rejects.toThrow();
expect(mockDestroyDispatcher).toHaveBeenCalledOnce();
expect(mockCloseDispatcher).not.toHaveBeenCalled();
});
it("never caches more departures than requested", async () => {
mockFetch.mockResolvedValue(
new Response(
JSON.stringify({
connections: Array.from({ length: 5 }, (_, index) => ({
time: `2026-01-01T00:0${index}:00Z`,
line: "1",
color: "ffffff",
terminal: { name: `Stop ${index}` },
dep_delay: "0",
})),
}),
),
);
const result = await timetableGetTimetableRequestHandler
.handler({ baseUrl: "http://timetable.internal", stationId: "bounded-departures", limit: 2 })
.getDataAsync();
expect(result.data.entries).toHaveLength(2);
});
});
+147 -52
View File
@@ -1,9 +1,11 @@
import type { LookupFunction } from "node:net";
import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createRequestHandler } from "./lib/request-handler";
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
export interface Station {
id: string;
@@ -31,51 +33,126 @@ export interface Timetable {
}
const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"];
const MAX_STATION_RESULTS = 100;
const MAX_TIMETABLE_ENTRIES = 100;
const timetableFetchOptions = { redirect: "error", timeout: 10_000, bodyTimeout: 10_000 } as const;
export interface TimetableResolvedAddress {
address: string;
family: 4 | 6;
}
export const timetableSearchStationsRequestHandler = createRequestHandler<
Station[],
{ baseUrl: string; query: string }
{ baseUrl: string; query: string; pinnedAddresses?: TimetableResolvedAddress[] }
>({
async requestAsync(input) {
return await searchStationsAsync(input.baseUrl, input.query);
return await searchStationsAsync(input.baseUrl, input.query, input.pinnedAddresses);
},
cacheTtlMs: 24 * 60 * 60 * 1000,
});
export const timetableGetTimetableRequestHandler = createRequestHandler<
Timetable,
{ baseUrl: string; stationId: string; limit: number }
{ baseUrl: string; stationId: string; limit: number; pinnedAddresses?: TimetableResolvedAddress[] }
>({
async requestAsync(input) {
return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit });
return await getTimetableAsync(
input.baseUrl,
{ stationId: input.stationId, limit: input.limit },
input.pinnedAddresses,
);
},
cacheTtlMs: 60_000,
});
const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record<string, string | number>) => {
const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`);
const url = new URL(`${normalizeTimetableBaseUrl(baseUrl)}${path}`);
for (const [key, value] of Object.entries(queryParams)) {
url.searchParams.set(key, value.toString());
}
return url.toString();
};
const searchStationsAsync = async (baseUrl: string, query: string): Promise<Station[]> => {
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
);
if (!response.ok) throw new ResponseError(response);
const normalizeLookupHostname = (hostname: string) =>
hostname
.replace(/^\[(.*)\]$/, "$1")
.toLowerCase()
.replace(/\.$/, "");
const data = await searchSchema.parseAsync(await response.json());
return data
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
.filter((item) => item !== null);
const createPinnedLookup = (baseUrl: string, addresses: TimetableResolvedAddress[]): LookupFunction => {
const expectedHostname = normalizeLookupHostname(new URL(baseUrl).hostname);
return (hostname, options, callback) => {
const requestedFamily = options.family === 4 || options.family === 6 ? options.family : undefined;
const candidates = requestedFamily ? addresses.filter(({ family }) => family === requestedFamily) : addresses;
if (normalizeLookupHostname(hostname) !== expectedHostname || candidates.length === 0) {
const error = new Error("Pinned timetable address is unavailable") as NodeJS.ErrnoException;
error.code = "ENOTFOUND";
callback(error, []);
return;
}
if (options.all) {
callback(null, candidates);
return;
}
const selected = candidates[0];
if (!selected) return callback(new Error("Pinned timetable address is unavailable"), []);
callback(null, selected.address, selected.family);
};
};
const createPinnedTimetableDispatcherAsync = async (baseUrl: string, addresses: TimetableResolvedAddress[]) => {
// Proxies resolve CONNECT targets independently, which would defeat DNS pinning. Force this security-sensitive
// configuration request to connect directly. The original URL remains unchanged for the Host header and TLS SNI.
return await createCertificateAgentAsync(
{ lookup: createPinnedLookup(baseUrl, addresses) },
{
autoSelectFamily: addresses.length > 1,
bodyTimeout: timetableFetchOptions.bodyTimeout,
httpProxy: "",
httpsProxy: "",
noProxy: "*",
},
);
};
const searchStationsAsync = async (
baseUrl: string,
query: string,
pinnedAddresses?: TimetableResolvedAddress[],
): Promise<Station[]> => {
let dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>> | undefined;
try {
response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
{ ...timetableFetchOptions, dispatcher },
);
if (!response.ok) throw new ResponseError(response);
const body = await readBoundedTimetableJsonAsync(response);
const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body);
return data
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
.filter((item) => item !== null);
} catch (error) {
if (response?.body) await response.body.cancel().catch(() => {});
await dispatcher?.destroy().catch(() => {});
dispatcher = undefined;
throw error;
} finally {
await dispatcher?.close();
}
};
const getTimetableAsync = async (
baseUrl: string,
options: { stationId: string; limit: number },
pinnedAddresses?: TimetableResolvedAddress[],
): Promise<Timetable> => {
const now = new Date();
const date = new Intl.DateTimeFormat("en-US", {
@@ -90,43 +167,59 @@ const getTimetableAsync = async (
minute: "2-digit",
hour12: false,
}).format(now);
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/stationboard.json", {
stop: options.stationId,
limit: options.limit,
show_delays: 1,
show_tracks: 1,
date,
time,
}),
);
if (!response.ok) throw new ResponseError(response);
let dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
let response: Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>> | undefined;
try {
response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/stationboard.json", {
stop: options.stationId,
limit: options.limit,
show_delays: 1,
show_tracks: 1,
date,
time,
}),
{ ...timetableFetchOptions, dispatcher },
);
if (!response.ok) throw new ResponseError(response);
const data = await timetableSchema.parseAsync(await response.json());
return {
stationId: options.stationId,
timestamp: now,
entries: data.connections.map((connection) => {
const color = connection.color.split("~")[0];
return {
timestamp: connection.time,
line: connection.line
? {
name: connection.line,
color: color && color.length >= 1 ? `#${color}` : null,
}
: null,
location: connection.terminal.name,
delay: connection.dep_delay,
platform: connection.track
? {
name: connection.track.replace("!", ""),
hasChanged: connection.track.includes("!"),
}
: null,
};
}),
};
const body = await readBoundedTimetableJsonAsync(response);
const envelope = timetableEnvelopeSchema.parse(body);
const data = await timetableSchema.parseAsync({
connections: envelope.connections.slice(0, Math.min(options.limit, MAX_TIMETABLE_ENTRIES)),
});
return {
stationId: options.stationId,
timestamp: now,
entries: data.connections.map((connection) => {
const color = connection.color.split("~")[0];
return {
timestamp: connection.time,
line: connection.line
? {
name: connection.line,
color: color && color.length >= 1 ? `#${color}` : null,
}
: null,
location: connection.terminal.name,
delay: connection.dep_delay,
platform: connection.track
? {
name: connection.track.replace("!", ""),
hasChanged: connection.track.includes("!"),
}
: null,
};
}),
};
} catch (error) {
if (response?.body) await response.body.cancel().catch(() => {});
await dispatcher?.destroy().catch(() => {});
dispatcher = undefined;
throw error;
} finally {
await dispatcher?.close();
}
};
const timetableSchema = z.object({
@@ -145,6 +238,8 @@ const timetableSchema = z.object({
),
});
const timetableEnvelopeSchema = z.object({ connections: z.array(z.unknown()) });
const searchSchema = z.array(
z.object({
id: z.string().optional(),
+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();
+5 -16
View File
@@ -1,28 +1,17 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
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";
const logger = createLogger({ module: "vpnRequestHandler" });
export const vpnSummaryHandler = createIntegrationRequestHandler<
VpnSummary | null,
VpnSummary,
IntegrationKindByCategory<"vpn">,
Record<string, never>
>({
cacheNamespace: "vpn:summary",
async requestAsync(integration, _input) {
try {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
} catch (error) {
logger.warn("Failed to fetch VPN summary, marking as unavailable", {
integrationId: integration.id,
integrationKind: integration.kind,
error: error instanceof Error ? error.message : String(error),
});
return null;
}
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
},
});
+135
View File
@@ -0,0 +1,135 @@
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type {
WazuhAgentsOverview,
WazuhAlert,
WazuhAlertTimeline,
WazuhAuthFailures,
WazuhCveOrder,
WazuhFimEventFilter,
WazuhFimOverview,
WazuhSecuritySummary,
WazuhTimelineInterval,
WazuhTimeRange,
WazuhTopList,
WazuhTopListKind,
WazuhVulnerabilityOverview,
} from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
/** Alert data changes constantly but the widgets only need minute resolution. */
const WAZUH_CACHE_TTL_MS = 60 * 1000;
/** Aggregations over long windows and the vulnerability state change slowly; cache them longer. */
const WAZUH_SLOW_CACHE_TTL_MS = 5 * 60 * 1000;
export const wazuhAgentsRequestHandler = createIntegrationRequestHandler<
WazuhAgentsOverview,
"wazuh",
Record<string, never>
>({
cacheNamespace: "wazuh:agents:v2",
cacheTtlMs: WAZUH_CACHE_TTL_MS,
async requestAsync(integration) {
const instance = await createIntegrationAsync(integration);
return await instance.getAgentsOverviewAsync();
},
});
export const wazuhAlertsRequestHandler = createIntegrationRequestHandler<
WazuhAlert[],
"wazuh",
{ minLevel: number; limit: number; hours: number }
>({
cacheNamespace: "wazuh:alerts:v2",
cacheTtlMs: WAZUH_CACHE_TTL_MS,
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return await instance.getRecentAlertsAsync(input);
},
});
export const wazuhSummaryRequestHandler = createIntegrationRequestHandler<
WazuhSecuritySummary,
"wazuh",
{ range: WazuhTimeRange }
>({
cacheNamespace: "wazuh:summary:v2",
cacheTtlMs: WAZUH_CACHE_TTL_MS,
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return await instance.getSecuritySummaryAsync(input);
},
});
const createTimelineRequestHandler = (cacheNamespace: string, cacheTtlMs: number) =>
createIntegrationRequestHandler<
WazuhAlertTimeline,
"wazuh",
{ range: WazuhTimeRange; interval: WazuhTimelineInterval; minLevel: number }
>({
cacheNamespace,
cacheTtlMs,
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return await instance.getAlertTimelineAsync(input);
},
});
const wazuhTimelineRequestHandler = createTimelineRequestHandler("wazuh:timeline", WAZUH_CACHE_TTL_MS);
const wazuhLongTimelineRequestHandler = createTimelineRequestHandler("wazuh:timeline:long", WAZUH_SLOW_CACHE_TTL_MS);
/** Long windows barely change between refreshes, so they are cached for longer. */
export const getWazuhTimelineRequestHandler = (range: WazuhTimeRange) =>
range === "7d" || range === "30d" ? wazuhLongTimelineRequestHandler : wazuhTimelineRequestHandler;
export const wazuhTopListRequestHandler = createIntegrationRequestHandler<
WazuhTopList,
"wazuh",
{ kind: WazuhTopListKind; range: WazuhTimeRange; limit: number; minLevel: number }
>({
cacheNamespace: "wazuh:top-list",
cacheTtlMs: WAZUH_CACHE_TTL_MS * 2,
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return await instance.getTopListAsync(input);
},
});
export const wazuhVulnerabilitiesRequestHandler = createIntegrationRequestHandler<
WazuhVulnerabilityOverview,
"wazuh",
{ cveLimit: number; agentLimit: number; cveOrder: WazuhCveOrder; minSeverity: "all" | "medium" | "high" | "critical" }
>({
cacheNamespace: "wazuh:vulnerabilities",
cacheTtlMs: WAZUH_SLOW_CACHE_TTL_MS,
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return await instance.getVulnerabilitiesAsync(input);
},
});
export const wazuhFimRequestHandler = createIntegrationRequestHandler<
WazuhFimOverview,
"wazuh",
{ range: WazuhTimeRange; event: WazuhFimEventFilter; limit: number; includeRegistry: boolean }
>({
cacheNamespace: "wazuh:fim",
cacheTtlMs: WAZUH_CACHE_TTL_MS,
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return await instance.getFimEventsAsync(input);
},
});
export const wazuhAuthFailuresRequestHandler = createIntegrationRequestHandler<
WazuhAuthFailures,
"wazuh",
{ hours: number; limit: number }
>({
cacheNamespace: "wazuh:auth-failures",
cacheTtlMs: WAZUH_CACHE_TTL_MS,
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return await instance.getAuthFailuresAsync(input);
},
});
@@ -0,0 +1,131 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { weatherRequestHandler } from "./weather";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const makeWeatherResponse = () => {
const hours = Array.from({ length: 25 }, (_, index) => `2026-07-01T${String(index % 24).padStart(2, "0")}:00`);
const days = [
"2026-06-30",
...Array.from({ length: 7 }, (_, index) => `2026-07-${String(index + 1).padStart(2, "0")}`),
];
const hourlyValues = hours.map<number | null>((_, index) => index);
const dailyValues = days.map<number | null>((_, index) => index);
const sunrise = days.map<string | null>((day) => `${day}T05:00`);
return {
timezone: "Europe/Berlin",
utc_offset_seconds: 7200,
current: {
time: "2026-07-01T12:00",
temperature_2m: 24,
relative_humidity_2m: 55,
apparent_temperature: 25,
is_day: 1,
precipitation: 0,
weather_code: 1,
wind_speed_10m: 12,
wind_gusts_10m: 20,
},
hourly: {
time: hours,
temperature_2m: hourlyValues,
apparent_temperature: hourlyValues,
precipitation_probability: hourlyValues,
precipitation: hourlyValues,
weather_code: hourlyValues,
relative_humidity_2m: hourlyValues,
wind_speed_10m: hourlyValues,
wind_gusts_10m: hourlyValues,
},
daily: {
time: days,
weather_code: dailyValues,
temperature_2m_max: dailyValues,
temperature_2m_min: dailyValues,
apparent_temperature_max: dailyValues,
apparent_temperature_min: dailyValues,
precipitation_probability_max: dailyValues,
precipitation_sum: dailyValues,
relative_humidity_2m_mean: dailyValues,
wind_speed_10m_max: dailyValues,
wind_gusts_10m_max: dailyValues,
uv_index_max: dailyValues,
sunrise,
sunset: days.map((day) => `${day}T21:00`),
daylight_duration: dailyValues,
},
};
};
beforeEach(() => {
mockFetch.mockReset();
weatherRequestHandler.invalidateCache();
});
describe("weather request handler", () => {
it("requests bounded useful forecasts and normalizes location times", async () => {
const response = makeWeatherResponse();
response.hourly.temperature_2m = [null];
Reflect.deleteProperty(response.hourly, "wind_gusts_10m");
response.daily.sunrise[1] = null;
mockFetch.mockResolvedValue(new Response(JSON.stringify(response)));
const first = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
const second = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
expect(first.data.current.observedAt).toBe("2026-07-01T10:00:00.000Z");
expect(first.data.hourly).toHaveLength(24);
expect(first.data.daily).toHaveLength(7);
expect(first.data.daily[0]?.date).toBe("2026-07-01");
expect(first.data.hourly[0]?.temperature).toBeNull();
expect(first.data.hourly[1]?.temperature).toBeNull();
expect(first.data.hourly[0]?.windGusts).toBeNull();
expect(first.data.daily[0]?.sunriseAt).toBeNull();
expect(first.data.daily[1]?.sunriseAt).toBe("2026-07-02T03:00:00.000Z");
expect(second.data).toStrictEqual(first.data);
expect(mockFetch).toHaveBeenCalledOnce();
const requestUrl = new URL(String(mockFetch.mock.calls[0]?.[0]));
expect(requestUrl.searchParams.get("forecast_hours")).toBe("24");
expect(requestUrl.searchParams.get("forecast_days")).toBe("7");
expect(requestUrl.searchParams.has("past_days")).toBe(false);
expect(requestUrl.searchParams.get("timezone")).toBe("auto");
expect(requestUrl.searchParams.get("current")).toContain("apparent_temperature");
expect(requestUrl.searchParams.get("hourly")).toContain("precipitation_probability");
expect(requestUrl.searchParams.get("daily")).toContain("uv_index_max");
});
it("retries one transient 503 before returning weather", async () => {
mockFetch
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
.mockResolvedValueOnce(new Response(JSON.stringify(makeWeatherResponse())));
const result = await weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync();
expect(result.data.current.temperature).toBe(24);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("rejects unsuccessful Open-Meteo responses after one retry", async () => {
mockFetch.mockResolvedValue(new Response("unavailable", { status: 503 }));
await expect(weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync()).rejects.toThrow();
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("does not retry other unsuccessful responses", async () => {
mockFetch.mockResolvedValue(new Response("unavailable", { status: 500 }));
await expect(weatherRequestHandler.handler({ latitude: 48.85, longitude: 2.35 }).getDataAsync()).rejects.toThrow();
expect(mockFetch).toHaveBeenCalledOnce();
});
});
+236 -44
View File
@@ -1,73 +1,265 @@
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { setTimeout } from "node:timers/promises";
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";
dayjs.extend(utc);
dayjs.extend(timezone);
const HOURLY_FORECAST_LENGTH = 24;
const DAILY_FORECAST_LENGTH = 7;
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());
url.searchParams.set(
"current",
[
"temperature_2m",
"relative_humidity_2m",
"apparent_temperature",
"is_day",
"precipitation",
"weather_code",
"wind_speed_10m",
"wind_gusts_10m",
].join(","),
);
url.searchParams.set(
"hourly",
[
"temperature_2m",
"apparent_temperature",
"precipitation_probability",
"precipitation",
"weather_code",
"relative_humidity_2m",
"wind_speed_10m",
"wind_gusts_10m",
].join(","),
);
url.searchParams.set(
"daily",
[
"weather_code",
"temperature_2m_max",
"temperature_2m_min",
"apparent_temperature_max",
"apparent_temperature_min",
"precipitation_probability_max",
"precipitation_sum",
"relative_humidity_2m_mean",
"wind_speed_10m_max",
"wind_gusts_10m_max",
"uv_index_max",
"sunrise",
"sunset",
"daylight_duration",
].join(","),
);
url.searchParams.set("timezone", "auto");
url.searchParams.set("forecast_hours", HOURLY_FORECAST_LENGTH.toString());
url.searchParams.set("forecast_days", DAILY_FORECAST_LENGTH.toString());
let res = await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
if (res.status === 503) {
await res.body?.cancel().catch(() => undefined);
// Retry one transient upstream outage while the server is already fetching
// for the board, before hydration starts a separate browser request.
await setTimeout(150, undefined, { signal });
res = await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
}
if (!res.ok) throw new ResponseError(res);
const json: unknown = await res.json();
const weather = await atLocationOutput.parseAsync(json);
const currentLocalDate = weather.current.time.slice(0, 10);
const matchedCurrentDayIndex = weather.daily.time.indexOf(currentLocalDate);
const currentDayIndex = matchedCurrentDayIndex < 0 ? 0 : matchedCurrentDayIndex;
const dailyIndexes = weather.daily.time
.map((_, index) => index)
.slice(currentDayIndex, currentDayIndex + DAILY_FORECAST_LENGTH);
return {
timezone: weather.timezone,
utcOffsetSeconds: weather.utc_offset_seconds,
current: {
observedAt: toUtcInstant(weather.current.time, weather.timezone),
temperature: weather.current.temperature_2m,
apparentTemperature: weather.current.apparent_temperature,
relativeHumidity: weather.current.relative_humidity_2m,
precipitation: weather.current.precipitation,
weatherCode: weather.current.weather_code,
weathercode: weather.current.weather_code,
windSpeed: weather.current.wind_speed_10m,
windspeed: weather.current.wind_speed_10m,
windGusts: weather.current.wind_gusts_10m,
isDay: weather.current.is_day === 1,
},
hourly: weather.hourly.time.slice(0, HOURLY_FORECAST_LENGTH).map((value, index) => {
return {
observedAt: toUtcInstant(value, weather.timezone),
temperature: weather.hourly.temperature_2m[index] ?? null,
apparentTemperature: weather.hourly.apparent_temperature[index] ?? null,
precipitationProbability: weather.hourly.precipitation_probability[index] ?? null,
precipitation: weather.hourly.precipitation[index] ?? null,
weatherCode: weather.hourly.weather_code[index] ?? null,
relativeHumidity: weather.hourly.relative_humidity_2m[index] ?? null,
windSpeed: weather.hourly.wind_speed_10m[index] ?? null,
windGusts: weather.hourly.wind_gusts_10m[index] ?? null,
};
}),
daily: dailyIndexes.map((index) => {
const value = weather.daily.time[index] ?? currentLocalDate;
return {
date: value,
time: value,
weatherCode: weather.daily.weather_code[index] ?? 404,
maxTemperature: weather.daily.temperature_2m_max[index] ?? null,
maxTemp: weather.daily.temperature_2m_max[index] ?? undefined,
minTemperature: weather.daily.temperature_2m_min[index] ?? null,
minTemp: weather.daily.temperature_2m_min[index] ?? undefined,
maxApparentTemperature: weather.daily.apparent_temperature_max[index] ?? null,
minApparentTemperature: weather.daily.apparent_temperature_min[index] ?? null,
precipitationProbability: weather.daily.precipitation_probability_max[index] ?? null,
precipitation: weather.daily.precipitation_sum[index] ?? null,
averageHumidity: weather.daily.relative_humidity_2m_mean[index] ?? null,
humidity: weather.daily.relative_humidity_2m_mean[index] ?? undefined,
maxWindSpeed: weather.daily.wind_speed_10m_max[index] ?? null,
maxWindGusts: weather.daily.wind_gusts_10m_max[index] ?? null,
uvIndex: weather.daily.uv_index_max[index] ?? null,
sunriseAt: toOptionalUtcInstant(weather.daily.sunrise[index], weather.timezone),
sunrise: weather.daily.sunrise[index] ?? undefined,
sunsetAt: toOptionalUtcInstant(weather.daily.sunset[index], weather.timezone),
sunset: weather.daily.sunset[index] ?? undefined,
daylightDuration: weather.daily.daylight_duration[index] ?? null,
};
}),
} satisfies Weather;
};
export const weatherRequestHandler = createWidgetRequestHandler({
async requestAsync(input: { latitude: number; longitude: number }) {
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://api.open-meteo.com/v1/forecast?latitude=${input.latitude}&longitude=${input.longitude}&daily=weathercode,temperature_2m_max,temperature_2m_min,sunrise,sunset,wind_speed_10m_max,wind_gusts_10m_max,relative_humidity_2m_mean&current_weather=true&timezone=auto`,
{ signal },
);
});
const json: unknown = await res.json();
const weather = await atLocationOutput.parseAsync(json);
return {
current: weather.current_weather,
daily: weather.daily.time.map((value, index) => {
return {
time: value,
weatherCode: weather.daily.weathercode[index] ?? 404,
maxTemp: weather.daily.temperature_2m_max[index],
minTemp: weather.daily.temperature_2m_min[index],
sunrise: weather.daily.sunrise[index],
sunset: weather.daily.sunset[index],
maxWindSpeed: weather.daily.wind_speed_10m_max[index],
maxWindGusts: weather.daily.wind_gusts_10m_max[index],
humidity: weather.daily.relative_humidity_2m_mean[index],
};
}),
} satisfies Weather;
},
// Coordinates are a public, continuous key space. Keep this cache process-local and bounded.
cacheTtlMs: 5 * 60 * 1000,
requestTimeoutMs: 10_000,
requestAsync: requestWeatherAsync,
});
const nullableNumberArray = z.array(z.number().nullable()).default([]);
const nullableStringArray = z.array(z.string().nullable()).default([]);
const atLocationOutput = z.object({
current_weather: z.object({
weathercode: z.number(),
temperature: z.number(),
windspeed: z.number(),
timezone: z.string().min(1),
utc_offset_seconds: z.number(),
current: z.object({
time: z.string(),
temperature_2m: z.number(),
relative_humidity_2m: z.number(),
apparent_temperature: z.number(),
is_day: z.number(),
precipitation: z.number(),
weather_code: z.number(),
wind_speed_10m: z.number(),
wind_gusts_10m: z.number(),
}),
hourly: z.object({
time: z.array(z.string()),
temperature_2m: nullableNumberArray,
apparent_temperature: nullableNumberArray,
precipitation_probability: nullableNumberArray,
precipitation: nullableNumberArray,
weather_code: nullableNumberArray,
relative_humidity_2m: nullableNumberArray,
wind_speed_10m: nullableNumberArray,
wind_gusts_10m: nullableNumberArray,
}),
daily: z.object({
time: z.array(z.string()),
weathercode: z.array(z.number()),
temperature_2m_max: z.array(z.number()),
temperature_2m_min: z.array(z.number()),
sunrise: z.array(z.string()),
sunset: z.array(z.string()),
wind_speed_10m_max: z.array(z.number()),
wind_gusts_10m_max: z.array(z.number()),
relative_humidity_2m_mean: z.array(z.number()),
weather_code: nullableNumberArray,
temperature_2m_max: nullableNumberArray,
temperature_2m_min: nullableNumberArray,
apparent_temperature_max: nullableNumberArray,
apparent_temperature_min: nullableNumberArray,
precipitation_probability_max: nullableNumberArray,
precipitation_sum: nullableNumberArray,
relative_humidity_2m_mean: nullableNumberArray,
wind_speed_10m_max: nullableNumberArray,
wind_gusts_10m_max: nullableNumberArray,
uv_index_max: nullableNumberArray,
sunrise: nullableStringArray,
sunset: nullableStringArray,
daylight_duration: nullableNumberArray,
}),
});
const toUtcInstant = (value: string, timeZone: string): string => {
const parsed = dayjs.tz(value, timeZone);
if (!parsed.isValid()) throw new Error(`Invalid weather timestamp: ${value}`);
return parsed.utc().toISOString();
};
const toOptionalUtcInstant = (value: string | null | undefined, timeZone: string): string | null => {
if (!value) return null;
return toUtcInstant(value, timeZone);
};
export interface Weather {
timezone: string;
utcOffsetSeconds: number;
current: {
weathercode: number;
temperature: number;
apparentTemperature: number;
relativeHumidity: number;
precipitation: number;
weatherCode: number;
weathercode: number;
windSpeed: number;
windspeed: number;
windGusts: number;
isDay: boolean;
observedAt: string;
};
hourly: {
observedAt: string;
temperature: number | null;
apparentTemperature: number | null;
precipitationProbability: number | null;
precipitation: number | null;
weatherCode: number | null;
relativeHumidity: number | null;
windSpeed: number | null;
windGusts: number | null;
}[];
daily: {
date: string;
time: string;
weatherCode: number;
maxTemperature: number | null;
maxTemp: number | undefined;
minTemperature: number | null;
minTemp: number | undefined;
sunrise: string | undefined;
sunset: string | undefined;
maxWindSpeed: number | undefined;
maxWindGusts: number | undefined;
maxApparentTemperature: number | null;
minApparentTemperature: number | null;
precipitationProbability: number | null;
precipitation: number | null;
averageHumidity: number | null;
humidity: number | undefined;
maxWindSpeed: number | null;
maxWindGusts: number | null;
uvIndex: number | null;
sunriseAt: string | null;
sunrise: string | undefined;
sunsetAt: string | null;
sunset: string | undefined;
daylightDuration: number | null;
}[];
}
+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();