merge: stack assistant on mobile boards

This commit is contained in:
Thomas Camlong committed 2026-08-08 21:27:56 +02:00
commit 9fd7a8a05b
1326 files changed
+131355 -46380

No files matched your search

@@ -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", () => ({
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);
});
});
+16 -3
View File
@@ -22,14 +22,27 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
assetCount: number;
}[],
IntegrationKindByCategory<"photoService">,
Record<string, never>
{ limit?: number }
>({
async requestAsync(integration) {
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">,
@@ -14,6 +14,7 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
}
export const createIntegrationRequestHandler = <
@@ -23,24 +24,18 @@ export const createIntegrationRequestHandler = <
>(
options: Options<TData, TKind, TInput>,
) => {
const integrationMap = new Map<string, IntegrationOfKind<TKind>>();
const inner = createRequestHandler<TData, { integrationId: string; options: TInput }>({
requestAsync: async (input) => {
const integration = integrationMap.get(input.integrationId);
if (!integration) {
throw new Error(`Integration ${input.integrationId} not found in cache`);
}
return options.requestAsync(integration, input.options);
},
const inner = createRequestHandler<TData, { integration: IntegrationOfKind<TKind>; options: TInput }>({
requestAsync: async ({ integration, options: itemOptions }) => options.requestAsync(integration, itemOptions),
getCacheKey: ({ integration, options: itemOptions }) =>
JSON.stringify({ integrationId: integration.id, itemOptions }),
cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
});
return {
invalidateCache: inner.invalidateCache,
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) => {
integrationMap.set(integration.id, integration);
return inner.handler({ integrationId: integration.id, options: itemOptions });
},
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
inner.handler({ integration, options: itemOptions }),
};
};
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
import { createIntegrationRequestHandler } from "./integration-request-handler";
import { createRequestHandler } from "./request-handler";
import { createWidgetRequestHandler } from "./widget-request-handler";
const deferred = <T>() => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
};
const createIntegration = (secret: string) =>
({
id: "integration-id",
kind: "sonarr",
decryptedSecrets: [{ kind: "apiKey", value: secret }],
}) as Omit<Integration, "kind"> & {
kind: "sonarr";
decryptedSecrets: Pick<IntegrationSecret, "kind" | "value">[];
externalUrl: string | null;
};
afterEach(() => {
vi.useRealTimers();
});
describe("createRequestHandler", () => {
it("deduplicates in-flight work and caches completed data", async () => {
let calls = 0;
const request = deferred<string>();
const handler = createRequestHandler({
requestAsync: async () => {
calls += 1;
return await request.promise;
},
});
const first = handler.handler({ id: "same" }).getDataAsync();
const second = handler.handler({ id: "same" }).getDataAsync();
expect(calls).toBe(1);
request.resolve("value");
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "value" });
expect(calls).toBe(1);
});
it("keeps in-flight deduplication but retains no payload when ttl is zero", async () => {
let calls = 0;
const request = deferred<{ call: number }>();
const handler = createRequestHandler({
cacheTtlMs: 0,
requestAsync: async () => {
calls += 1;
if (calls === 1) return await request.promise;
return { call: calls };
},
});
const first = handler.handler({ id: "same" }).getDataAsync();
const second = handler.handler({ id: "same" }).getDataAsync();
expect(calls).toBe(1);
request.resolve({ call: 1 });
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: { call: 1 } }, { data: { call: 1 } }]);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 2 } });
expect(calls).toBe(2);
});
it("releases positive-ttl entries after expiry without another request", async () => {
vi.useFakeTimers();
let calls = 0;
const handler = createRequestHandler({
cacheTtlMs: 25,
requestAsync: async () => ({ call: ++calls }),
});
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 1 } });
await vi.advanceTimersByTimeAsync(25);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: { call: 2 } });
expect(calls).toBe(2);
});
it("cancels scheduled expiry work when the cache is invalidated", async () => {
vi.useFakeTimers();
const handler = createRequestHandler({ cacheTtlMs: 25, requestAsync: async () => "value" });
await handler.handler({ id: "same" }).getDataAsync();
expect(vi.getTimerCount()).toBe(1);
handler.invalidateCache();
expect(vi.getTimerCount()).toBe(0);
});
it("bounds stale-on-error fallback and releases it while idle", async () => {
vi.useFakeTimers();
let calls = 0;
const handler = createRequestHandler({
cacheTtlMs: 25,
fallbackToStaleOnError: true,
staleIfErrorTtlMs: 50,
requestAsync: async () => {
calls += 1;
if (calls === 1) return "last-known-good";
throw new Error("upstream unavailable");
},
});
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({
data: "last-known-good",
});
await vi.advanceTimersByTimeAsync(25);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({
data: "last-known-good",
});
await vi.advanceTimersByTimeAsync(51);
await expect(handler.handler({ id: "same" }).getDataAsync()).rejects.toThrow("upstream unavailable");
expect(calls).toBe(3);
});
it("reschedules staggered stale entries by their retirement deadline without spinning", async () => {
vi.useFakeTimers();
const handler = createRequestHandler({
cacheTtlMs: 10,
fallbackToStaleOnError: true,
staleIfErrorTtlMs: 100,
requestAsync: async ({ id }: { id: string }) => id,
});
await handler.handler({ id: "first" }).getDataAsync();
await vi.advanceTimersByTimeAsync(20);
await handler.handler({ id: "second" }).getDataAsync();
expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(90);
expect(vi.getTimerCount()).toBe(1);
await vi.advanceTimersByTimeAsync(20);
expect(vi.getTimerCount()).toBe(0);
});
it("evicts an empty-string cache key at the size limit", async () => {
let calls = 0;
const handler = createRequestHandler({
cacheTtlMs: 60_000,
getCacheKey: ({ id }: { id: string }) => id,
requestAsync: async () => ++calls,
});
await handler.handler({ id: "" }).getDataAsync();
for (let index = 0; index < 1000; index += 1) {
await handler.handler({ id: String(index) }).getDataAsync();
}
await handler.handler({ id: "" }).getDataAsync();
expect(calls).toBe(1002);
});
it("does not let a pre-invalidation request repopulate or clear newer work", async () => {
const staleRequest = deferred<string>();
const freshRequest = deferred<string>();
const requests = [staleRequest, freshRequest];
let calls = 0;
const handler = createRequestHandler({
requestAsync: async () => {
const request = requests[calls++];
if (!request) throw new Error("Unexpected request");
return await request.promise;
},
});
const stale = handler.handler({ id: "same" }).getDataAsync();
handler.invalidateCache();
const fresh = handler.handler({ id: "same" }).getDataAsync();
staleRequest.resolve("stale");
await expect(stale).resolves.toMatchObject({ data: "stale" });
const joinsFresh = handler.handler({ id: "same" }).getDataAsync();
expect(calls).toBe(2);
freshRequest.resolve("fresh");
await expect(Promise.all([fresh, joinsFresh])).resolves.toMatchObject([{ data: "fresh" }, { data: "fresh" }]);
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "fresh" });
expect(calls).toBe(2);
});
});
describe("createIntegrationRequestHandler", () => {
it("keys requests by integration id and options without retaining secret values", async () => {
let calls = 0;
const request = deferred<string>();
const handler = createIntegrationRequestHandler<string, "sonarr", { page: number }>({
requestAsync: async () => {
calls += 1;
return await request.promise;
},
});
const first = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync();
const second = handler.handler(createIntegration("rotated-secret"), { page: 1 }).getDataAsync();
expect(calls).toBe(1);
request.resolve("value");
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
});
});
describe("createWidgetRequestHandler", () => {
it("shares its cache across calls while keeping distinct inputs separate", async () => {
let calls = 0;
const handler = createWidgetRequestHandler({ requestAsync: async ({ id }: { id: string }) => `${id}-${++calls}` });
await expect(handler.handler({ id: "one" }).getDataAsync()).resolves.toMatchObject({ data: "one-1" });
await expect(handler.handler({ id: "one" }).getDataAsync()).resolves.toMatchObject({ data: "one-1" });
await expect(handler.handler({ id: "two" }).getDataAsync()).resolves.toMatchObject({ data: "two-2" });
expect(calls).toBe(2);
});
});
@@ -1,18 +1,21 @@
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
getCacheKey?: (input: TInput) => string;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
}
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number; staleUntil: number };
const MAX_CACHE_SIZE = 1000;
const DEFAULT_TTL_MS = 10_000;
const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000;
const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
const now = Date.now();
for (const [key, entry] of cache) {
if (now >= entry.expiresAt) cache.delete(key);
if (now >= entry.staleUntil) cache.delete(key);
}
};
@@ -21,44 +24,98 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
) => {
const cache = new Map<string, CacheEntry<TData>>();
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
let expiryTimerAt: number | undefined;
let generation = 0;
const scheduleExpiry = (candidateExpiryAt?: number) => {
if (candidateExpiryAt !== undefined && expiryTimerAt !== undefined && expiryTimerAt <= candidateExpiryAt) return;
if (expiryTimer) clearTimeout(expiryTimer);
expiryTimer = undefined;
expiryTimerAt = undefined;
if (cache.size === 0) return;
const nextExpiryAt = candidateExpiryAt ?? Math.min(...[...cache.values()].map(({ staleUntil }) => staleUntil));
expiryTimerAt = nextExpiryAt;
expiryTimer = setTimeout(
() => {
expiryTimer = undefined;
expiryTimerAt = undefined;
evictExpired(cache);
scheduleExpiry();
},
Math.max(0, nextExpiryAt - Date.now()),
);
expiryTimer.unref?.();
};
return {
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 ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
const key = JSON.stringify(input);
const key = options.getCacheKey?.(input) ?? JSON.stringify(input);
const cached = cache.get(key);
if (cached && Date.now() < cached.expiresAt) {
let cached = cache.get(key);
const now = Date.now();
if (cached && now < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
if (cached && now >= cached.staleUntil) {
cache.delete(key);
scheduleExpiry();
cached = undefined;
}
const existing = inflight.get(key);
if (existing) return existing;
const promise = options
const requestGeneration = generation;
let promise: Promise<CacheEntry<TData>>;
promise = options
.requestAsync(input)
.then((data) => {
if (cache.size >= MAX_CACHE_SIZE) evictExpired(cache);
if (cache.size >= MAX_CACHE_SIZE) {
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
const expiresAt = Date.now() + ttl;
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,
};
if (ttl > 0 && generation === requestGeneration) {
evictExpired(cache);
if (cache.size >= MAX_CACHE_SIZE) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, entry);
scheduleExpiry(entry.staleUntil);
}
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
cache.set(key, entry);
inflight.delete(key);
return entry;
})
.catch((err) => {
inflight.delete(key);
if (options.fallbackToStaleOnError && cached) {
if (
options.fallbackToStaleOnError &&
cached &&
Date.now() < cached.staleUntil &&
generation === requestGeneration
) {
return cached;
}
throw err;
})
.finally(() => {
if (inflight.get(key) === promise) inflight.delete(key);
});
inflight.set(key, promise);
@@ -6,9 +6,7 @@ interface Options<TData, TInput extends Record<string, unknown>> {
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => ({
handler: (widgetOptions: TInput) =>
createRequestHandler<TData, TInput>({
requestAsync: options.requestAsync,
}).handler(widgetOptions),
});
) => {
const inner = createRequestHandler<TData, TInput>({ requestAsync: options.requestAsync });
return inner;
};
@@ -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,10 +1,29 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
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 }
>({
@@ -16,6 +35,6 @@ 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;
},
});
@@ -0,0 +1,244 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => {
type StoredValue = {
expiresAt: number | null;
value: unknown;
};
const values = new Map<string, StoredValue>();
const locks = new Set<string>();
const listReleases = vi.fn();
const redisOperation = vi.fn();
return {
env: { NO_EXTERNAL_CONNECTION: false },
listReleases,
redisOperation,
values,
locks,
removeFreshResult() {
for (const key of values.keys()) {
if (key.startsWith("update-checker:fresh:")) values.delete(key);
}
},
};
});
vi.mock("@homarr/common/env", () => ({ env: mocks.env }));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
vi.mock("@homarr/core/infrastructure/logs", () => ({
createLogger: () => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
}),
}));
vi.mock("@homarr/redis", () => ({
createGetSetChannel: (name: string) => ({
getAsync: async () => {
mocks.redisOperation();
const stored = mocks.values.get(name);
if (!stored) return null;
if (stored.expiresAt !== null && Date.now() >= stored.expiresAt) {
mocks.values.delete(name);
return null;
}
return stored.value;
},
setAsync: async (value: unknown, options?: { ttlSeconds?: number }) => {
mocks.redisOperation();
mocks.values.set(name, {
value,
expiresAt: options?.ttlSeconds ? Date.now() + options.ttlSeconds * 1_000 : null,
});
},
removeAsync: async () => {
mocks.redisOperation();
mocks.values.delete(name);
},
}),
createLockChannel: (name: string) => ({
acquireAsync: async () => {
mocks.redisOperation();
if (mocks.locks.has(name)) return null;
mocks.locks.add(name);
return "test-lock-token";
},
releaseAsync: async () => {
mocks.redisOperation();
mocks.locks.delete(name);
},
}),
}));
vi.mock("octokit", () => ({
Octokit: class {
rest = {
repos: {
listReleases: mocks.listReleases,
},
};
},
}));
const successfulResponse = {
data: [
{
body_html: undefined,
html_url: "https://github.com/homarr-labs/homarr/releases/tag/v99.0.0",
name: "v99.0.0",
prerelease: false,
tag_name: "v99.0.0",
},
],
headers: {},
status: 200,
url: "",
};
const loadHandlerAsync = async () => {
const { updateCheckerRequestHandler } = await import("../update-checker");
return updateCheckerRequestHandler.handler({});
};
describe("persisted update checker cache", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
mocks.env.NO_EXTERNAL_CONNECTION = false;
mocks.listReleases.mockReset();
mocks.redisOperation.mockReset();
mocks.values.clear();
mocks.locks.clear();
});
test("checks GitHub once for repeated page loads inside 24 hours", async () => {
mocks.listReleases.mockResolvedValue(successfulResponse);
const handler = await loadHandlerAsync();
const first = await handler.getDataAsync();
const second = await handler.getDataAsync();
expect(first.data).toEqual(second.data);
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
});
test("deduplicates concurrent checks", async () => {
mocks.listReleases.mockImplementation(async () => {
await Promise.resolve();
return successfulResponse;
});
const handler = await loadHandlerAsync();
const results = await Promise.all(Array.from({ length: 10 }, async () => await handler.getDataAsync()));
expect(results.every(({ data }) => data.availableUpdates.length === 1)).toBe(true);
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
});
test("deduplicates concurrent checks across simulated processes", async () => {
let completeGitHubRequest: (() => void) | undefined;
mocks.listReleases.mockImplementation(
async () =>
await new Promise<typeof successfulResponse>((resolve) => {
completeGitHubRequest = () => resolve(successfulResponse);
}),
);
const firstHandler = await loadHandlerAsync();
vi.resetModules();
const secondHandler = await loadHandlerAsync();
const firstRequest = firstHandler.getDataAsync();
await vi.waitFor(() => expect(mocks.listReleases).toHaveBeenCalledOnce());
const secondRequest = secondHandler.getDataAsync();
completeGitHubRequest?.();
const results = await Promise.all([firstRequest, secondRequest]);
expect(results[0].data).toEqual(results[1].data);
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
});
test("refreshes at the 24-hour boundary, not before it", async () => {
let now = 1_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
mocks.listReleases.mockResolvedValue(successfulResponse);
const handler = await loadHandlerAsync();
await handler.getDataAsync();
now += 24 * 60 * 60 * 1_000 - 1;
await handler.getDataAsync();
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
now += 1;
await handler.getDataAsync();
expect(mocks.listReleases).toHaveBeenCalledTimes(2);
});
test("reuses the Redis result after a simulated process restart", async () => {
mocks.listReleases.mockResolvedValue(successfulResponse);
const firstHandler = await loadHandlerAsync();
await firstHandler.getDataAsync();
vi.resetModules();
const restartedHandler = await loadHandlerAsync();
await restartedHandler.getDataAsync();
expect(mocks.listReleases).toHaveBeenCalledTimes(1);
});
test("ignores update results created for an older application version", async () => {
mocks.values.set("update-checker:fresh:v1", {
value: { availableUpdates: [], attemptedAt: 1, checkedAt: 1 },
expiresAt: null,
});
mocks.listReleases.mockResolvedValue(successfulResponse);
const handler = await loadHandlerAsync();
const result = await handler.getDataAsync();
expect(result.data.availableUpdates).toHaveLength(1);
expect(mocks.listReleases).toHaveBeenCalledOnce();
});
test("serves stale data and retries only after 24 hours following a provider failure", async () => {
let now = 1_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
mocks.listReleases.mockResolvedValueOnce(successfulResponse).mockRejectedValue(new Error("GitHub unavailable"));
const handler = await loadHandlerAsync();
const fresh = await handler.getDataAsync();
mocks.removeFreshResult();
const stale = await handler.getDataAsync();
now += 24 * 60 * 60 * 1_000 - 1;
const repeated = await handler.getDataAsync();
expect(stale.data).toEqual(fresh.data);
expect(repeated.data).toEqual(fresh.data);
expect(mocks.listReleases).toHaveBeenCalledTimes(2);
now += 1;
const retried = await handler.getDataAsync();
expect(retried.data).toEqual(fresh.data);
expect(mocks.listReleases).toHaveBeenCalledTimes(3);
});
test("does not contact GitHub when external connections are disabled", async () => {
mocks.env.NO_EXTERNAL_CONNECTION = true;
const handler = await loadHandlerAsync();
await expect(handler.getDataAsync()).resolves.toMatchObject({ data: { availableUpdates: [] } });
expect(mocks.listReleases).not.toHaveBeenCalled();
expect(mocks.redisOperation).not.toHaveBeenCalled();
});
});
@@ -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(),
+102 -7
View File
@@ -5,19 +5,114 @@ import { env } from "@homarr/common/env";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createRequestHandler } from "./lib/request-handler";
import { createGetSetChannel, createLockChannel } from "@homarr/redis";
import packageJson from "../../../package.json";
const logger = createLogger({ module: "updateCheckerRequestHandler" });
export const updateCheckerRequestHandler = createRequestHandler({
async requestAsync(_) {
const updateCheckTtlSeconds = 24 * 60 * 60;
const updateCheckLockTtlSeconds = 2 * 60;
const updateCheckWaitIntervalMs = 100;
const updateCheckWaitAttempts = 50;
type UpdateCheckCacheEntry = {
availableUpdates: Update[];
attemptedAt: number;
checkedAt: number | null;
};
const updateCheckCacheVersion = `v2:${packageJson.version}`;
const freshUpdateCheckChannel = createGetSetChannel<UpdateCheckCacheEntry>(
`update-checker:fresh:${updateCheckCacheVersion}`,
);
const staleUpdateCheckChannel = createGetSetChannel<UpdateCheckCacheEntry>(
`update-checker:stale:${updateCheckCacheVersion}`,
);
const updateCheckLock = createLockChannel(`update-checker:lock:${updateCheckCacheVersion}`);
const waitAsync = async (durationMs: number) =>
await new Promise<void>((resolve) => {
setTimeout(resolve, durationMs);
});
const waitForConcurrentUpdateCheckAsync = async () => {
for (let attempt = 0; attempt < updateCheckWaitAttempts; attempt++) {
await waitAsync(updateCheckWaitIntervalMs);
const cached = await freshUpdateCheckChannel.getAsync();
if (cached) return cached;
}
return await staleUpdateCheckChannel.getAsync();
};
const getCachedAvailableUpdatesAsync = async (): Promise<UpdateCheckCacheEntry> => {
if (env.NO_EXTERNAL_CONNECTION) {
return {
availableUpdates: [],
attemptedAt: Date.now(),
checkedAt: null,
};
}
const cached = await freshUpdateCheckChannel.getAsync();
if (cached) return cached;
const staleBeforeRefresh = await staleUpdateCheckChannel.getAsync();
const lockToken = await updateCheckLock.acquireAsync(updateCheckLockTtlSeconds);
if (!lockToken) {
if (staleBeforeRefresh) return staleBeforeRefresh;
const concurrentResult = await waitForConcurrentUpdateCheckAsync();
if (concurrentResult) return concurrentResult;
throw new Error("Timed out waiting for the update check");
}
try {
const attemptedAt = Date.now();
const availableUpdates = await getAvailableUpdatesAsync(packageJson.version);
return { availableUpdates };
},
cacheTtlMs: 30 * 60 * 1000,
});
const result: UpdateCheckCacheEntry = {
availableUpdates,
attemptedAt,
checkedAt: attemptedAt,
};
await staleUpdateCheckChannel.setAsync(result);
await freshUpdateCheckChannel.setAsync(result, { ttlSeconds: updateCheckTtlSeconds });
return result;
} catch (error) {
const stale = await staleUpdateCheckChannel.getAsync();
const result: UpdateCheckCacheEntry = {
availableUpdates: stale?.availableUpdates ?? [],
attemptedAt: Date.now(),
checkedAt: stale?.checkedAt ?? null,
};
await freshUpdateCheckChannel.setAsync(result, { ttlSeconds: updateCheckTtlSeconds });
logger.warn("Failed to fetch available updates; suppressing retries for 24 hours", {
error: error instanceof Error ? error.message : String(error),
servingStale: stale !== null,
});
return result;
} finally {
try {
await updateCheckLock.releaseAsync(lockToken);
} catch (error) {
logger.warn("Failed to release the update-check lock", {
error: error instanceof Error ? error.message : String(error),
});
}
}
};
export const updateCheckerRequestHandler = {
handler: (_input: Record<string, never>) => ({
getDataAsync: async () => {
const result = await getCachedAvailableUpdatesAsync();
return {
data: { availableUpdates: result.availableUpdates },
timestamp: new Date(result.checkedAt ?? result.attemptedAt),
};
},
}),
};
interface Update {
name: string | null;
+3 -15
View File
@@ -1,28 +1,16 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { VpnSummary } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const logger = createLogger({ module: "vpnRequestHandler" });
export const vpnSummaryHandler = createIntegrationRequestHandler<
VpnSummary | null,
VpnSummary,
IntegrationKindByCategory<"vpn">,
Record<string, never>
>({
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();
},
});