refactor widget-only integrations

This commit is contained in:
Thomas Camlong committed 2026-06-27 10:57:12 +02:00
1 parent f3608b26a0
commit fb348044fb
122 files changed
+2259 -3016

No files matched your search

@@ -1,20 +1,331 @@
import { randomUUID } from "node:crypto";
import { Buffer } from "node:buffer";
import dayjs from "dayjs";
import WebSocket from "ws";
import type { RawData } from "ws";
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createWidgetOptionsChannel } from "@homarr/redis";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createCachedRequestHandler } from "./lib/cached-request-handler";
export const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHandler<
ArchiveTeamWarriorStatus,
IntegrationKindByCategory<"archiving">,
Record<string, never>
>({
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatusAsync();
},
cacheDuration: dayjs.duration(5, "minute"),
queryKey: "archiveTeamWarriorStatus",
const SOCKJS_SNAPSHOT_TIMEOUT_MS = 2500;
export interface ArchiveTeamWarriorItem {
id: string;
name: string;
status: "running" | "completed" | "failed" | "canceled" | "unknown";
project?: string | null;
startTime?: number;
}
export interface ArchiveTeamWarriorBandwidth {
received?: number;
sent?: number;
receiving?: number;
sending?: number;
session_id?: string;
}
export interface ArchiveTeamWarriorProject {
id?: string;
title: string;
}
export interface ArchiveTeamWarriorStatus {
status: string;
runnerStatus?: string;
project?: ArchiveTeamWarriorProject | null;
selectedProject: string | null;
broadcastMessage: string | null;
bandwidth?: ArchiveTeamWarriorBandwidth | null;
items: ArchiveTeamWarriorItem[];
counts: { running: number; completed: number; failed: number; canceled: number };
updatedAt: string;
}
interface WarriorSnapshotState {
status: string;
runnerStatus?: string;
selectedProject: string | null;
broadcastMessage: string | null;
project: ArchiveTeamWarriorStatus["project"];
bandwidth: ArchiveTeamWarriorStatus["bandwidth"];
items: Map<string, ArchiveTeamWarriorItem>;
}
export const archiveTeamWarriorRequestHandler = {
handler: (itemOptions: { url: string }) =>
createCachedRequestHandler<ArchiveTeamWarriorStatus, { url: string }>({
async requestAsync(input) {
return await getStatusAsync(input.url);
},
cacheDuration: dayjs.duration(5, "minute"),
queryKey: "archiveTeamWarriorStatus",
createRedisChannel(input, handlerOptions) {
return createWidgetOptionsChannel<ArchiveTeamWarriorStatus>(
"archiveTeamWarrior",
handlerOptions.queryKey,
input,
);
},
}).handler(itemOptions),
};
const getStatusAsync = async (url: string): Promise<ArchiveTeamWarriorStatus> => {
const baseUrl = url.replace(/\/$/, "");
await fetchWithTrustedCertificatesAsync(`${baseUrl}/index.html`);
return await readSockJsSnapshotAsync(baseUrl);
};
const readSockJsSnapshotAsync = async (baseUrl: string): Promise<ArchiveTeamWarriorStatus> => {
const snapshot: WarriorSnapshotState = {
status: "Unknown",
selectedProject: null,
broadcastMessage: null,
project: null,
bandwidth: null,
items: new Map<string, ArchiveTeamWarriorItem>(),
};
await new Promise<void>((resolve, reject) => {
let isSettled = false;
const socket = new WebSocket(createSockJsWebSocketUrl(baseUrl));
const finish = () => {
if (isSettled) return;
isSettled = true;
clearTimeout(timeout);
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close();
resolve();
};
const fail = () => {
if (isSettled) return;
isSettled = true;
clearTimeout(timeout);
reject(new Error("Failed to connect to ArchiveTeam Warrior websocket"));
};
const timeout = setTimeout(finish, SOCKJS_SNAPSHOT_TIMEOUT_MS);
socket.on("message", (data: RawData) => {
for (const frame of parseSockJsMessage(decodeWebSocketData(data))) {
applyFrameToSnapshot(frame, snapshot);
}
});
socket.on("error", fail);
socket.on("close", finish);
});
return createStatusFromSnapshot(snapshot);
};
const applyFrameToSnapshot = (frame: SeesawFrame, snapshot: WarriorSnapshotState) => {
switch (frame.event_name) {
case "warrior.status":
case "runner.status": {
const message = warriorStatusMessageSchema.safeParse(frame.message);
if (!message.success) return;
if (frame.event_name === "warrior.status") snapshot.status = normalizeDisplayStatus(message.data.status);
else snapshot.runnerStatus = normalizeDisplayStatus(message.data.status);
return;
}
case "warrior.project_selected": {
const message = warriorProjectSelectedMessageSchema.safeParse(frame.message);
if (message.success) snapshot.selectedProject = message.data.project;
return;
}
case "warrior.broadcast_message": {
const message = warriorBroadcastMessageSchema.safeParse(frame.message);
if (message.success) snapshot.broadcastMessage = message.data.message;
return;
}
case "project.refresh": {
const message = projectRefreshMessageSchema.safeParse(frame.message);
if (!message.success) return;
snapshot.project = mapProject(message.data?.project);
snapshot.runnerStatus = message.data?.status ? normalizeDisplayStatus(message.data.status) : snapshot.runnerStatus;
for (const item of message.data?.items ?? []) snapshot.items.set(item.id, mapItem(item));
return;
}
case "pipeline.start_item": {
const message = pipelineStartItemMessageSchema.safeParse(frame.message);
if (message.success) snapshot.items.set(message.data.item.id, mapItem(message.data.item));
return;
}
case "item.complete":
updateItemStatus(frame, snapshot, "completed");
return;
case "item.fail":
updateItemStatus(frame, snapshot, "failed");
return;
case "item.cancel":
updateItemStatus(frame, snapshot, "canceled");
return;
case "bandwidth": {
const message = bandwidthMessageSchema.safeParse(frame.message);
if (message.success) snapshot.bandwidth = message.data;
}
}
};
const updateItemStatus = (
frame: SeesawFrame,
snapshot: WarriorSnapshotState,
status: ArchiveTeamWarriorItem["status"],
) => {
const message = itemStatusMessageSchema.safeParse(frame.message);
if (!message.success) return;
const item = snapshot.items.get(message.data.item_id);
if (item) snapshot.items.set(message.data.item_id, { ...item, status });
};
const createStatusFromSnapshot = (snapshot: WarriorSnapshotState): ArchiveTeamWarriorStatus => {
const itemList = Array.from(snapshot.items.values());
return {
status: snapshot.status,
runnerStatus: snapshot.runnerStatus,
project: snapshot.project,
selectedProject: snapshot.selectedProject,
broadcastMessage: snapshot.broadcastMessage,
bandwidth: snapshot.bandwidth,
items: itemList,
counts: {
running: itemList.filter((item) => item.status === "running").length,
completed: itemList.filter((item) => item.status === "completed").length,
failed: itemList.filter((item) => item.status === "failed").length,
canceled: itemList.filter((item) => item.status === "canceled").length,
},
updatedAt: new Date().toISOString(),
};
};
const decodeWebSocketData = (data: RawData) => {
if (typeof data === "string") return data;
if (Buffer.isBuffer(data)) return data.toString("utf8");
if (Array.isArray(data)) return Buffer.concat(data).toString("utf8");
return Buffer.from(data).toString("utf8");
};
const parseSockJsMessage = (raw: string): SeesawFrame[] => {
if (raw === "o" || raw === "h" || raw.length === 0) return [];
if (!raw.startsWith("a")) return [];
const payload = z.array(z.string()).safeParse(parseJsonSafely(raw.slice(1)));
if (!payload.success) return [];
return payload.data.flatMap((frame) => {
const result = seesawFrameSchema.safeParse(parseJsonSafely(frame));
return result.success ? [result.data] : [];
});
};
const parseJsonSafely = (value: string) => {
try {
return JSON.parse(value) as unknown;
} catch {
return null;
}
};
const createSockJsWebSocketUrl = (baseUrl: string) => {
const url = new URL(baseUrl);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.pathname = `/${Date.now()}/${randomUUID()}/websocket`;
return url.toString();
};
const mapProject = (project?: WarriorProject): ArchiveTeamWarriorStatus["project"] => {
if (!project) return null;
return { id: project.project_id?.toString(), title: project.title ?? "Unknown Project" };
};
const mapItem = (item: WarriorItem): ArchiveTeamWarriorItem => ({
id: item.id,
name: formatItemName(item.name),
status: normalizeItemStatus(item.status),
project: item.project,
startTime: item.start_time,
});
const normalizeDisplayStatus = (status?: string | null) => {
if (!status) return "Unknown";
switch (status) {
case "RUNNING_PROJECT":
return "Running";
case "STOPPED":
return "Stopped";
case "IDLE":
return "Idle";
default:
return status
.replaceAll("_", " ")
.toLowerCase()
.replace(/\b\w/g, (character) => character.toUpperCase());
}
};
const formatItemName = (name: string) => {
const parts = name.replace(/^Item\s+/i, "").split("\u0000").filter(Boolean);
const first = parts[0];
if (!first) return "Item";
const label = parts.length === 1 ? first : `${first} + ${parts.length - 1} more`;
return label.length <= 80 ? label : `${label.slice(0, 77)}...`;
};
const normalizeItemStatus = (status?: string | null): ArchiveTeamWarriorItem["status"] => {
switch (status?.toLowerCase()) {
case "running":
case "running_project":
return "running";
case "completed":
return "completed";
case "failed":
case "fail":
return "failed";
case "canceled":
case "cancel":
return "canceled";
case undefined:
return "running";
default:
return "unknown";
}
};
const seesawFrameSchema = z.object({ event_name: z.string(), message: z.unknown() });
const warriorStatusMessageSchema = z.object({ status: z.string() });
const warriorBroadcastMessageSchema = z.object({ message: z.string().nullable() });
const warriorProjectSelectedMessageSchema = z.object({ project: z.string().nullable() });
const warriorProjectSchema = z.object({
project_id: z.number().optional(),
title: z.string().optional(),
project_html: z.string().optional(),
utc_deadline: z.string().nullable().optional(),
});
const warriorItemSchema = z.object({
id: z.string(),
name: z.string(),
status: z.string().nullable().optional(),
project: z.string().nullable().optional(),
start_time: z.number().optional(),
});
const projectRefreshMessageSchema = z
.object({
project: warriorProjectSchema.optional(),
status: z.string().optional(),
items: z.array(warriorItemSchema).optional(),
})
.nullable();
const itemStatusMessageSchema = z.object({ item_id: z.string() });
const pipelineStartItemMessageSchema = z.object({ item: warriorItemSchema });
const bandwidthMessageSchema = z.object({
received: z.number().optional(),
sent: z.number().optional(),
receiving: z.number().optional(),
sending: z.number().optional(),
session_id: z.string().optional(),
});
type WarriorProject = z.infer<typeof warriorProjectSchema>;
type WarriorItem = z.infer<typeof warriorItemSchema>;
type SeesawFrame = z.infer<typeof seesawFrameSchema>;
@@ -0,0 +1,647 @@
import { Octokit, RequestError as OctokitRequestError } from "octokit";
import { z } from "zod/v4";
import type { ReleaseProviderKind } from "@homarr/definitions";
import { getReleaseProviderDefaultUrl, normalizeReleaseProviderIdentifier } from "@homarr/definitions";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
const logger = createLogger({ module: "releaseProviders" });
export interface ReleasesRepositoryRequest extends Record<string, unknown> {
id: string;
provider: ReleaseProviderKind;
identifier: string;
versionRegex?: string;
providerUrl?: string;
}
export interface DetailsProviderResponse {
projectUrl?: string;
projectDescription?: string;
isFork?: boolean;
isArchived?: boolean;
createdAt?: Date;
starsCount?: number;
openIssues?: number;
forksCount?: number;
}
export interface ReleaseProviderResponse {
latestRelease: string;
latestReleaseAt: Date;
releaseUrl?: string;
releaseDescription?: string;
isPreRelease?: boolean;
}
export type ReleaseData = DetailsProviderResponse & ReleaseProviderResponse;
export type ReleaseError =
| { code: "invalidIdentifier" | "noMatchingVersion" | "noReleasesFound" }
| { code: "unexpected"; message: string };
export type ReleaseResponse = { success: true; data: ReleaseData } | { success: false; error: ReleaseError };
export const getLatestRelease = (
releases: ReleaseProviderResponse[],
versionRegex?: string,
): ReleaseProviderResponse | null => {
const validReleases = releases.filter((result) => {
if (result.latestRelease) return versionRegex ? new RegExp(versionRegex).test(result.latestRelease) : true;
return true;
});
return validReleases.length === 0
? null
: validReleases.reduce((latest, current) => (current.latestReleaseAt > latest.latestReleaseAt ? current : latest));
};
export const getLatestMatchingReleaseAsync = async (input: ReleasesRepositoryRequest): Promise<ReleaseResponse> => {
const baseUrl = removeTrailingSlash(input.providerUrl ?? getReleaseProviderDefaultUrl(input.provider));
const identifier = normalizeReleaseProviderIdentifier(input.provider, input.identifier);
switch (input.provider) {
case "github":
return await getGithubReleaseAsync(baseUrl, identifier, input.versionRegex);
case "gitHubContainerRegistry":
return await getGitHubContainerRegistryReleaseAsync(baseUrl, identifier, input.versionRegex);
case "dockerHub":
return await getDockerHubReleaseAsync(baseUrl, identifier, input.versionRegex);
case "gitlab":
return await getGitlabReleaseAsync(baseUrl, identifier, input.versionRegex);
case "npm":
return await getNpmReleaseAsync(baseUrl, identifier, input.versionRegex);
case "codeberg":
return await getCodebergReleaseAsync(baseUrl, identifier, input.versionRegex);
case "linuxServerIO":
return await getLinuxServerIOReleaseAsync(baseUrl, identifier);
case "quay":
return await getQuayReleaseAsync(baseUrl, identifier, input.versionRegex);
}
};
const parseOwnerName = (identifier: string) => {
const [owner, name] = identifier.split("/");
if (!owner || !name) return null;
return { owner, name };
};
const parseOwnerPackageName = (identifier: string) => {
const [owner, ...nameParts] = identifier.split("/");
const name = nameParts.join("/");
if (!owner || !name) return null;
return { owner, name };
};
const removeTrailingSlash = (url: string) => url.replace(/\/$/, "");
const buildUrl = (baseUrl: string, path: `/${string}`) => new URL(`${baseUrl}${path}`);
const getGithubApi = (baseUrl: string, userAgent: string) =>
new Octokit({
baseUrl,
request: { fetch: fetchWithTrustedCertificatesAsync },
throttle: { enabled: false },
userAgent,
});
const getGithubReleaseAsync = async (
baseUrl: string,
identifier: string,
versionRegex?: string,
): Promise<ReleaseResponse> => {
const parsed = parseOwnerName(identifier);
if (!parsed) return { success: false, error: { code: "invalidIdentifier" } };
const api = getGithubApi(baseUrl, "Homarr-Lab/Homarr:GithubReleaseProvider");
try {
const releasesResponse = await api.rest.repos.listReleases({ owner: parsed.owner, repo: parsed.name });
if (releasesResponse.data.length === 0) return { success: false, error: { code: "noMatchingVersion" } };
const releases = releasesResponse.data.reduce<ReleaseProviderResponse[]>((acc, release) => {
if (!release.published_at) return acc;
acc.push({
latestRelease: release.tag_name,
latestReleaseAt: new Date(release.published_at),
releaseUrl: release.html_url,
releaseDescription: release.body ?? undefined,
isPreRelease: release.prerelease,
});
return acc;
}, []);
const latestRelease = getLatestRelease(releases, versionRegex);
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
const details = await getGithubDetailsAsync(api, parsed.owner, parsed.name);
return { success: true, data: { ...details, ...latestRelease } };
} catch (error) {
const message = error instanceof OctokitRequestError ? error.message : String(error);
logger.warn("Failed to get GitHub releases", { identifier, error: message });
return { success: false, error: { code: "unexpected", message } };
}
};
const getGithubDetailsAsync = async (api: Octokit, owner: string, name: string): Promise<DetailsProviderResponse> => {
try {
const response = await api.rest.repos.get({ owner, repo: name });
return {
projectUrl: response.data.html_url,
projectDescription: response.data.description ?? undefined,
isFork: response.data.fork,
isArchived: response.data.archived,
createdAt: new Date(response.data.created_at),
starsCount: response.data.stargazers_count,
openIssues: response.data.open_issues_count,
forksCount: response.data.forks_count,
};
} catch (error) {
logger.warn("Failed to get GitHub details", { owner, name, error: String(error) });
return {};
}
};
const getGitHubContainerRegistryReleaseAsync = async (
baseUrl: string,
identifier: string,
versionRegex?: string,
): Promise<ReleaseResponse> => {
const parsed = parseOwnerPackageName(identifier);
if (!parsed) return { success: false, error: { code: "invalidIdentifier" } };
try {
const repositoryName = `${parsed.owner}/${parsed.name}`;
const registryUrl = new URL(baseUrl);
const token = await getGitHubContainerRegistryTokenAsync(registryUrl, repositoryName);
const tags = await getGitHubContainerRegistryTagsAsync(registryUrl, repositoryName, token);
const matchingTags = versionRegex ? tags.filter((tag) => new RegExp(versionRegex).test(tag)) : tags;
const tagsToCheck = versionRegex
? matchingTags.slice(-100)
: matchingTags.includes("latest")
? ["latest"]
: matchingTags.slice(-100);
if (tagsToCheck.length === 0) return { success: false, error: { code: "noMatchingVersion" } };
const releases = await Promise.all(
tagsToCheck.map(async (tag) => ({
latestRelease: tag,
latestReleaseAt: await getGitHubContainerRegistryTagCreatedAtAsync(registryUrl, repositoryName, tag, token),
releaseUrl: `https://github.com/${repositoryName}/pkgs/container/${encodeURIComponent(parsed.name)}`,
})),
);
const latestRelease = getLatestRelease(releases, versionRegex);
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
return {
success: true,
data: {
projectUrl: `https://github.com/${repositoryName}`,
...latestRelease,
},
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.warn("Failed to get GitHub container registry releases", { identifier, error: message });
return { success: false, error: { code: "unexpected", message } };
}
};
const gitHubContainerRegistryTokenSchema = z.object({ token: z.string() });
const gitHubContainerRegistryTagsSchema = z.object({ tags: z.array(z.string()).optional() });
const gitHubContainerRegistryManifestSchema = z.object({
config: z.object({ digest: z.string() }).optional(),
manifests: z.array(z.object({ digest: z.string() })).optional(),
});
const gitHubContainerRegistryConfigSchema = z.object({
created: z.string().transform((value) => new Date(value)),
});
const getGitHubContainerRegistryTokenAsync = async (registryUrl: URL, repositoryName: string) => {
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(
registryUrl.origin,
`/token?service=${registryUrl.host}&scope=${encodeURIComponent(`repository:${repositoryName}:pull`)}` as `/${string}`,
),
);
if (!response.ok) throw new Error(response.statusText);
const result = gitHubContainerRegistryTokenSchema.safeParse(await response.json());
if (!result.success) throw new Error(result.error.message);
return result.data.token;
};
const getGitHubContainerRegistryTagsAsync = async (registryUrl: URL, repositoryName: string, token: string) => {
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(registryUrl.origin, `/v2/${repositoryName}/tags/list`),
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!response.ok) throw new Error(response.statusText);
const result = gitHubContainerRegistryTagsSchema.safeParse(await response.json());
if (!result.success) throw new Error(result.error.message);
return result.data.tags ?? [];
};
const getGitHubContainerRegistryTagCreatedAtAsync = async (
registryUrl: URL,
repositoryName: string,
tag: string,
token: string,
) => {
const manifest = await getGitHubContainerRegistryManifestAsync(registryUrl, repositoryName, tag, token);
const imageManifest = manifest.config
? manifest
: await getGitHubContainerRegistryManifestAsync(
registryUrl,
repositoryName,
manifest.manifests?.[0]?.digest ?? "",
token,
);
const configDigest = imageManifest.config?.digest;
if (!configDigest) throw new Error("Missing image config digest");
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(registryUrl.origin, `/v2/${repositoryName}/blobs/${configDigest}`),
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!response.ok) throw new Error(response.statusText);
const result = gitHubContainerRegistryConfigSchema.safeParse(await response.json());
if (!result.success) throw new Error(result.error.message);
return result.data.created;
};
const getGitHubContainerRegistryManifestAsync = async (
registryUrl: URL,
repositoryName: string,
reference: string,
token: string,
) => {
if (!reference) throw new Error("Missing image manifest reference");
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(registryUrl.origin, `/v2/${repositoryName}/manifests/${reference}`),
{
headers: {
Authorization: `Bearer ${token}`,
Accept: [
"application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v2+json",
].join(", "),
},
},
);
if (!response.ok) throw new Error(response.statusText);
const result = gitHubContainerRegistryManifestSchema.safeParse(await response.json());
if (!result.success) throw new Error(result.error.message);
return result.data;
};
const dockerHubReleasesSchema = z.object({
results: z.array(
z.object({ name: z.string(), last_updated: z.string().transform((value) => new Date(value)) }).transform((tag) => ({
latestRelease: tag.name,
latestReleaseAt: tag.last_updated,
})),
),
});
const dockerHubDetailsSchema = z.object({
name: z.string(),
namespace: z.string(),
description: z.string(),
star_count: z.number(),
date_registered: z.string().transform((value) => new Date(value)),
});
const getDockerHubReleaseAsync = async (
baseUrl: string,
identifier: string,
versionRegex?: string,
): Promise<ReleaseResponse> => {
const parsed = identifier.includes("/") ? parseOwnerName(identifier) : { owner: "", name: identifier };
if (!parsed) return { success: false, error: { code: "invalidIdentifier" } };
const relativeUrl = parsed.owner
? `/v2/namespaces/${encodeURIComponent(parsed.owner)}/repositories/${encodeURIComponent(parsed.name)}`
: `/v2/repositories/library/${encodeURIComponent(parsed.name)}`;
for (let page = 0; page <= 5; page++) {
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, `${relativeUrl}/tags?page_size=100&page=${page}` as `/${string}`),
);
if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } };
const json: unknown = await response.json();
const result = dockerHubReleasesSchema.safeParse(json);
if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } };
const latestRelease = getLatestRelease(result.data.results, versionRegex);
if (!latestRelease) continue;
const details = await getDockerHubDetailsAsync(baseUrl, relativeUrl as `/${string}`);
return { success: true, data: { ...details, ...latestRelease } };
}
return { success: false, error: { code: "noMatchingVersion" } };
};
const getDockerHubDetailsAsync = async (
baseUrl: string,
relativeUrl: `/${string}`,
): Promise<DetailsProviderResponse> => {
const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, `${relativeUrl}/` as `/${string}`));
if (!response.ok) return {};
const result = dockerHubDetailsSchema.safeParse(await response.json());
if (!result.success) return {};
return {
projectUrl: `https://hub.docker.com/r/${result.data.namespace}/${result.data.name}`,
projectDescription: result.data.description,
createdAt: result.data.date_registered,
starsCount: result.data.star_count,
};
};
const gitlabReleasesSchema = z.array(
z.object({
name: z.string().nullable().optional(),
tag_name: z.string(),
released_at: z.string().nullable().optional(),
description: z.string().nullable().optional(),
_links: z.object({ self: z.string().optional() }).optional(),
}),
);
const gitlabDetailsSchema = z.object({
web_url: z.string().optional(),
description: z.string().nullable().optional(),
forked_from_project: z.unknown().nullable().optional(),
archived: z.boolean(),
created_at: z.string(),
star_count: z.number(),
open_issues_count: z.number().optional(),
forks_count: z.number(),
});
const getGitlabReleaseAsync = async (
baseUrl: string,
identifier: string,
versionRegex?: string,
): Promise<ReleaseResponse> => {
const encodedIdentifier = encodeURIComponent(identifier);
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}/releases?per_page=100`),
);
if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } };
const result = gitlabReleasesSchema.safeParse(await response.json());
if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } };
if (result.data.length === 0) return { success: false, error: { code: "noReleasesFound" } };
const releases = result.data.reduce<ReleaseProviderResponse[]>((acc, release) => {
if (!release.released_at) return acc;
const releaseDate = new Date(release.released_at);
acc.push({
latestRelease: release.name ?? release.tag_name,
latestReleaseAt: releaseDate,
releaseUrl: release._links?.self,
releaseDescription: release.description ?? undefined,
isPreRelease: releaseDate > new Date(),
});
return acc;
}, []);
const latestRelease = getLatestRelease(releases, versionRegex);
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
const details = await getGitlabDetailsAsync(baseUrl, encodedIdentifier);
return { success: true, data: { ...details, ...latestRelease } };
};
const getGitlabDetailsAsync = async (baseUrl: string, encodedIdentifier: string): Promise<DetailsProviderResponse> => {
const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}`));
if (!response.ok) return {};
const result = gitlabDetailsSchema.safeParse(await response.json());
if (!result.success || !result.data.web_url) return {};
return {
projectUrl: result.data.web_url,
projectDescription: result.data.description ?? undefined,
isFork: result.data.forked_from_project !== null,
isArchived: result.data.archived,
createdAt: new Date(result.data.created_at),
starsCount: result.data.star_count,
openIssues: result.data.open_issues_count,
forksCount: result.data.forks_count,
};
};
const npmReleasesSchema = z.object({
time: z
.record(
z.string(),
z.string().transform((value) => new Date(value)),
)
.transform((version) =>
Object.entries(version).map(([key, value]) => ({ latestRelease: key, latestReleaseAt: value })),
),
versions: z.record(z.string(), z.object({ description: z.string() })),
name: z.string(),
});
const getNpmReleaseAsync = async (
baseUrl: string,
identifier: string,
versionRegex?: string,
): Promise<ReleaseResponse> => {
if (!identifier) return { success: false, error: { code: "invalidIdentifier" } };
const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, `/${encodeURIComponent(identifier)}`));
if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } };
const result = npmReleasesSchema.safeParse(await response.json());
if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } };
const releases = result.data.time.map((tag) => ({
...tag,
releaseUrl: `https://www.npmjs.com/package/${encodeURIComponent(result.data.name)}/v/${encodeURIComponent(tag.latestRelease)}`,
releaseDescription: result.data.versions[tag.latestRelease]?.description ?? "",
}));
const latestRelease = getLatestRelease(releases, versionRegex);
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
return { success: true, data: latestRelease };
};
const codebergReleasesSchema = z.array(
z.object({
tag_name: z.string(),
published_at: z.string().transform((value) => new Date(value)),
url: z.string(),
body: z.string(),
prerelease: z.boolean(),
}),
);
const codebergDetailsSchema = z.object({
html_url: z.string(),
description: z.string(),
fork: z.boolean(),
archived: z.boolean(),
created_at: z.string().transform((value) => new Date(value)),
stars_count: z.number(),
open_issues_count: z.number(),
forks_count: z.number(),
});
const getCodebergReleaseAsync = async (
baseUrl: string,
identifier: string,
versionRegex?: string,
): Promise<ReleaseResponse> => {
const parsed = parseOwnerName(identifier);
if (!parsed) return { success: false, error: { code: "invalidIdentifier" } };
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, `/api/v1/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}/releases`),
);
if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } };
const result = codebergReleasesSchema.safeParse(await response.json());
if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } };
const releases = result.data.map((tag) => ({
latestRelease: tag.tag_name,
latestReleaseAt: tag.published_at,
releaseUrl: tag.url,
releaseDescription: tag.body,
isPreRelease: tag.prerelease,
}));
const latestRelease = getLatestRelease(releases, versionRegex);
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
const details = await getCodebergDetailsAsync(baseUrl, parsed.owner, parsed.name);
return { success: true, data: { ...details, ...latestRelease } };
};
const getCodebergDetailsAsync = async (
baseUrl: string,
owner: string,
name: string,
): Promise<DetailsProviderResponse> => {
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`),
);
if (!response.ok) return {};
const result = codebergDetailsSchema.safeParse(await response.json());
if (!result.success) return {};
return {
projectUrl: result.data.html_url,
projectDescription: result.data.description,
isFork: result.data.fork,
isArchived: result.data.archived,
createdAt: result.data.created_at,
starsCount: result.data.stars_count,
openIssues: result.data.open_issues_count,
forksCount: result.data.forks_count,
};
};
const linuxServerIOReleasesSchema = z.object({
data: z.object({
repositories: z.object({
linuxserver: z.array(
z.object({
name: z.string(),
initial_date: z
.string()
.transform((value) => new Date(value))
.optional(),
github_url: z.string(),
description: z.string(),
version: z.string(),
version_timestamp: z.string().transform((value) => new Date(value)),
stars: z.number(),
deprecated: z.boolean(),
changelog: z.array(z.object({ date: z.string(), desc: z.string() })).optional(),
}),
),
}),
}),
});
const getLinuxServerIOReleaseAsync = async (baseUrl: string, identifier: string): Promise<ReleaseResponse> => {
const parsed = parseOwnerName(identifier);
if (!parsed) return { success: false, error: { code: "invalidIdentifier" } };
const response = await fetchWithTrustedCertificatesAsync(buildUrl(baseUrl, "/api/v1/images"));
if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } };
const result = linuxServerIOReleasesSchema.safeParse(await response.json());
if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } };
const release = result.data.data.repositories.linuxserver.find((repo) => repo.name === parsed.name);
if (!release) return { success: false, error: { code: "noMatchingVersion" } };
return {
success: true,
data: {
latestRelease: release.version,
latestReleaseAt: release.version_timestamp,
releaseDescription: release.changelog?.shift()?.desc,
projectUrl: release.github_url,
projectDescription: release.description,
isArchived: release.deprecated,
createdAt: release.initial_date,
starsCount: release.stars,
},
};
};
const quayReleasesSchema = z.object({
description: z.string().optional(),
tags: z.record(z.string(), z.object({ name: z.string(), last_modified: z.string() })),
});
const getQuayReleaseAsync = async (
baseUrl: string,
identifier: string,
versionRegex?: string,
): Promise<ReleaseResponse> => {
const parsed = parseOwnerName(identifier);
if (!parsed) return { success: false, error: { code: "invalidIdentifier" } };
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(
baseUrl,
`/api/v1/repository/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}?includeTags=true&includeStats=true`,
),
);
if (!response.ok) return { success: false, error: { code: "unexpected", message: response.statusText } };
const result = quayReleasesSchema.safeParse(await response.json());
if (!result.success) return { success: false, error: { code: "unexpected", message: result.error.message } };
const releases = Object.values(result.data.tags).reduce<ReleaseProviderResponse[]>((acc, tag) => {
if (!tag.name || !tag.last_modified) return acc;
acc.push({
latestRelease: tag.name,
latestReleaseAt: new Date(tag.last_modified),
releaseUrl: `https://quay.io/repository/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}/tag/${encodeURIComponent(tag.name)}`,
});
return acc;
}, []);
const latestRelease = getLatestRelease(releases, versionRegex);
if (!latestRelease) return { success: false, error: { code: "noMatchingVersion" } };
return { success: true, data: { projectDescription: result.data.description, ...latestRelease } };
};
+15 -16
View File
@@ -1,20 +1,19 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { ReleaseResponse, ReleasesRepository } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createWidgetOptionsChannel } from "@homarr/redis";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createCachedRequestHandler } from "./lib/cached-request-handler";
import type { ReleaseResponse, ReleasesRepositoryRequest } from "./release-providers";
import { getLatestMatchingReleaseAsync } from "./release-providers";
export const releasesRequestHandler = createCachedIntegrationRequestHandler<
ReleaseResponse,
IntegrationKindByCategory<"releasesProvider">,
ReleasesRepository
>({
requestAsync: async (integration, input) => {
const instance = await createIntegrationAsync(integration);
return instance.getLatestMatchingReleaseAsync(input.identifier, input.versionRegex);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "repositoriesReleases",
});
export const releasesRequestHandler = {
handler: (itemOptions: ReleasesRepositoryRequest) =>
createCachedRequestHandler<ReleaseResponse, ReleasesRepositoryRequest>({
requestAsync: async (input) => await getLatestMatchingReleaseAsync(input),
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "repositoriesReleases",
createRedisChannel(input, handlerOptions) {
return createWidgetOptionsChannel<ReleaseResponse>("releases", handlerOptions.queryKey, input);
},
}).handler(itemOptions),
};
@@ -0,0 +1,189 @@
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);
describe("getLatestMatchingReleaseAsync for GitHub Container Registry", () => {
beforeEach(() => {
mockedFetch.mockReset();
});
test("normalizes full image refs and returns latest from anonymous OCI registry metadata", async () => {
mockGhcrResponses({
repositoryName: "homarr-labs/homarr",
tags: ["v1.0.0", "latest"],
createdAtByTag: {
latest: "2026-06-19T20:30:59.853Z",
},
});
const result = await getLatestMatchingReleaseAsync({
id: "homarr",
provider: "gitHubContainerRegistry",
identifier: "ghcr.io/homarr-labs/homarr:latest",
});
expect(result).toEqual({
success: true,
data: {
projectUrl: "https://github.com/homarr-labs/homarr",
latestRelease: "latest",
latestReleaseAt: new Date("2026-06-19T20:30:59.853Z"),
releaseUrl: "https://github.com/homarr-labs/homarr/pkgs/container/homarr",
},
});
expectUrl(0, "https://ghcr.io/token");
expect(new URL(mockedFetch.mock.calls[0]?.[0].toString() ?? "").searchParams.get("service")).toBe("ghcr.io");
expect(new URL(mockedFetch.mock.calls[0]?.[0].toString() ?? "").searchParams.get("scope")).toBe(
"repository:homarr-labs/homarr:pull",
);
});
test("uses custom registry base URL for GHCR-compatible registries", async () => {
mockGhcrResponses({
registryOrigin: "https://registry.example.com",
repositoryName: "homarr-labs/homarr",
tags: ["latest"],
createdAtByTag: {
latest: "2026-06-19T20:30:59.853Z",
},
});
await getLatestMatchingReleaseAsync({
id: "homarr",
provider: "gitHubContainerRegistry",
identifier: "homarr-labs/homarr",
providerUrl: "https://registry.example.com/",
});
expectUrl(0, "https://registry.example.com/token");
expect(new URL(mockedFetch.mock.calls[0]?.[0].toString() ?? "").searchParams.get("service")).toBe(
"registry.example.com",
);
expectUrl(1, "https://registry.example.com/v2/homarr-labs/homarr/tags/list");
});
test("chooses the newest matching version tag", async () => {
mockGhcrResponses({
repositoryName: "muchobien/pocketbase",
tags: ["0.22.0", "0.23.0", "latest"],
createdAtByTag: {
"0.22.0": "2026-01-01T00:00:00.000Z",
"0.23.0": "2026-02-01T00:00:00.000Z",
},
});
const result = await getLatestMatchingReleaseAsync({
id: "pocketbase",
provider: "gitHubContainerRegistry",
identifier: "ghcr.io/muchobien/pocketbase:latest",
versionRegex: "^0\\.[0-9]+\\.[0-9]+$",
});
expect(result.success).toBe(true);
if (!result.success) return;
expect(result.data.latestRelease).toBe("0.23.0");
expect(result.data.latestReleaseAt).toEqual(new Date("2026-02-01T00:00:00.000Z"));
});
test("returns invalidIdentifier for missing package name", async () => {
const result = await getLatestMatchingReleaseAsync({
id: "invalid",
provider: "gitHubContainerRegistry",
identifier: "ghcr.io/homarr-labs",
});
expect(result).toEqual({ success: false, error: { code: "invalidIdentifier" } });
expect(mockedFetch).not.toHaveBeenCalled();
});
test("returns unexpected with provider error message", async () => {
mockedFetch.mockResolvedValueOnce(createJsonResponse({ errors: [] }, { status: 500, statusText: "Registry down" }));
const result = await getLatestMatchingReleaseAsync({
id: "homarr",
provider: "gitHubContainerRegistry",
identifier: "homarr-labs/homarr",
});
expect(result).toEqual({ success: false, error: { code: "unexpected", message: "Registry down" } });
});
});
interface MockGhcrResponsesInput {
registryOrigin?: string;
repositoryName: string;
tags: string[];
createdAtByTag: Record<string, string>;
}
const mockGhcrResponses = ({
registryOrigin = "https://ghcr.io",
repositoryName,
tags,
createdAtByTag,
}: MockGhcrResponsesInput) => {
mockedFetch.mockImplementation(async (input) => {
const url = new URL(input.toString());
const path = url.pathname;
if (url.origin !== registryOrigin) {
return createJsonResponse({ error: "unexpected origin" }, { status: 404, statusText: "Not Found" });
}
if (path === "/token") return createJsonResponse({ token: "token" });
if (path === `/v2/${repositoryName}/tags/list`) return createJsonResponse({ name: repositoryName, tags });
const manifestMatch = new RegExp(`^/v2/${escapeRegExp(repositoryName)}/manifests/(.+)$`).exec(path);
if (manifestMatch) {
const reference = decodeURIComponent(manifestMatch[1] ?? "");
if (reference.startsWith("sha256:")) {
return createJsonResponse({ config: { digest: `sha256:config-${reference.slice("sha256:".length)}` } });
}
return createJsonResponse({
manifests: [{ digest: `sha256:${reference}` }],
});
}
const blobMatch = new RegExp(`^/v2/${escapeRegExp(repositoryName)}/blobs/sha256:config-(.+)$`).exec(path);
if (blobMatch) {
const tag = decodeURIComponent(blobMatch[1] ?? "");
return createJsonResponse({ created: createdAtByTag[tag] ?? "2026-01-01T00:00:00.000Z" });
}
return createJsonResponse({ error: "not found" }, { status: 404, statusText: "Not Found" });
});
};
interface JsonResponseInit {
status?: number;
statusText?: string;
}
const createJsonResponse = (body: unknown, init: JsonResponseInit = {}) =>
new Response(JSON.stringify(body), {
status: init.status ?? 200,
statusText: init.statusText,
headers: {
"content-type": "application/json",
},
});
const expectUrl = (callIndex: number, expectedUrl: string) => {
expect(mockedFetch.mock.calls[callIndex]?.[0].toString().split("?")[0]).toBe(expectedUrl);
};
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+147 -30
View File
@@ -1,36 +1,153 @@
import dayjs from "dayjs";
import z from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { Station, Timetable } from "@homarr/integrations/types";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createWidgetOptionsChannel } from "@homarr/redis";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createCachedRequestHandler } from "./lib/cached-request-handler";
export const timetableSearchStationsRequestHandler = createCachedIntegrationRequestHandler<
Station[],
IntegrationKindByCategory<"timetable">,
{ query: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.searchStationsAsync(input.query);
},
queryKey: "timetableSearchStations",
cacheDuration: dayjs.duration(1, "day"),
export interface Station {
id: string;
name: string;
}
export interface TimetableEntry {
timestamp: Date;
delay: number;
line: {
name: string;
color: string | null;
} | null;
location: string;
platform: {
name: string;
hasChanged: boolean;
} | null;
}
export interface Timetable {
stationId: string;
timestamp: Date;
entries: TimetableEntry[];
}
const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"];
export const timetableSearchStationsRequestHandler = {
handler: (itemOptions: { baseUrl: string; query: string }) =>
createCachedRequestHandler<Station[], { baseUrl: string; query: string }>({
async requestAsync(input) {
return await searchStationsAsync(input.baseUrl, input.query);
},
queryKey: "timetableSearchStations",
cacheDuration: dayjs.duration(1, "day"),
createRedisChannel(input, handlerOptions) {
return createWidgetOptionsChannel<Station[]>("timetable", handlerOptions.queryKey, input);
},
}).handler(itemOptions),
};
export const timetableGetTimetableRequestHandler = {
handler: (itemOptions: { baseUrl: string; stationId: string; limit: number }) =>
createCachedRequestHandler<Timetable, { baseUrl: string; stationId: string; limit: number }>({
async requestAsync(input) {
return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit });
},
queryKey: "timetableGetTimetable",
cacheDuration: dayjs.duration(1, "minute"),
createRedisChannel(input, handlerOptions) {
return createWidgetOptionsChannel<Timetable>("timetable", handlerOptions.queryKey, input);
},
}).handler(itemOptions),
};
const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record<string, string | number>) => {
const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`);
for (const [key, value] of Object.entries(queryParams)) {
url.searchParams.set(key, value.toString());
}
return url;
};
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 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 getTimetableAsync = async (
baseUrl: string,
options: { stationId: string; limit: number },
): Promise<Timetable> => {
const now = new Date();
const [date, time] = now.toLocaleString("en-CH", { timeZone: "Europe/Zurich" }).split(", ") as [string, string];
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/stationboard.json", {
stop: options.stationId,
limit: options.limit,
show_delays: 1,
show_tracks: 1,
date,
time: time.substring(0, 5),
}),
);
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 timetableSchema = z.object({
connections: z.array(
z.object({
time: z.string().transform((str) => new Date(str)),
line: z.string().optional(),
color: z.string(),
terminal: z.object({ name: z.string() }),
track: z.string().optional(),
dep_delay: z
.string()
.optional()
.transform((str) => (str ? parseInt(str) : 0)),
}),
),
});
export const timetableGetTimetableRequestHandler = createCachedIntegrationRequestHandler<
Timetable,
IntegrationKindByCategory<"timetable">,
{ stationId: string; limit: number }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getTimetableAsync({
stationId: input.stationId,
limit: input.limit,
});
},
queryKey: "timetableGetTimetable",
cacheDuration: dayjs.duration(1, "minute"),
});
const searchSchema = z.array(
z.object({
id: z.string().optional(),
label: z.string(),
iconclass: z.string(),
}),
);