merge: origin/dev (widget-only integration refactor)
Resolve conflicts keeping our simplified request handlers (with TTL cache) and adopting dev's new release-provider + timetable direct implementations. Remove per-query staleTime from timetable widget.
This commit is contained in:
91 files changed
+2306
-2545
No files matched your search
@@ -0,0 +1,829 @@
|
||||
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" });
|
||||
|
||||
const PROVIDER_TIMEOUT_MS = 10_000;
|
||||
const fetchProvider = (url: URL | RequestInfo, options?: RequestInit & { timeout?: number }) =>
|
||||
fetchWithTrustedCertificatesAsync(url, { timeout: PROVIDER_TIMEOUT_MS, ...options });
|
||||
|
||||
export interface ReleasesRepositoryRequest extends Record<string, unknown> {
|
||||
id: string;
|
||||
provider: ReleaseProviderKind;
|
||||
identifier: string;
|
||||
versionRegex?: string;
|
||||
providerUrl?: string;
|
||||
token?: 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" | "unexpected";
|
||||
message: string;
|
||||
};
|
||||
export type ReleaseResponse = { success: true; data: ReleaseData } | { success: false; error: ReleaseError };
|
||||
|
||||
const compileVersionRegex = (versionRegex?: string): RegExp | null => {
|
||||
if (!versionRegex) return null;
|
||||
try {
|
||||
return new RegExp(versionRegex);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const getLatestRelease = (
|
||||
releases: ReleaseProviderResponse[],
|
||||
versionRegex?: string,
|
||||
): { release: ReleaseProviderResponse } | { noMatch: string } => {
|
||||
const regex = compileVersionRegex(versionRegex);
|
||||
if (versionRegex && !regex) {
|
||||
return { noMatch: `Invalid regex: /${versionRegex}/` };
|
||||
}
|
||||
|
||||
const validReleases = releases.filter((result) => {
|
||||
if (!result.latestRelease) return false;
|
||||
return regex ? regex.test(result.latestRelease) : true;
|
||||
});
|
||||
|
||||
if (validReleases.length > 0) {
|
||||
return {
|
||||
release: validReleases.reduce((latest, current) =>
|
||||
current.latestReleaseAt > latest.latestReleaseAt ? current : latest,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const sampleTags = releases
|
||||
.slice(0, 5)
|
||||
.map((r) => r.latestRelease)
|
||||
.join(", ");
|
||||
const reason = versionRegex
|
||||
? `Regex /${versionRegex}/ matched 0/${releases.length} tags. Sample tags: [${sampleTags}]`
|
||||
: `Found ${releases.length} releases but none had a valid tag`;
|
||||
return { noMatch: reason };
|
||||
};
|
||||
|
||||
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, input.token);
|
||||
case "gitHubContainerRegistry":
|
||||
return await getGitHubContainerRegistryReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
|
||||
case "dockerHub":
|
||||
return await getDockerHubReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
|
||||
case "gitlab":
|
||||
return await getGitlabReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
|
||||
case "npm":
|
||||
return await getNpmReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
|
||||
case "codeberg":
|
||||
return await getCodebergReleaseAsync(baseUrl, identifier, input.versionRegex, input.token);
|
||||
case "linuxServerIO":
|
||||
return await getLinuxServerIOReleaseAsync(baseUrl, identifier, input.versionRegex);
|
||||
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, token?: string) =>
|
||||
new Octokit({
|
||||
baseUrl,
|
||||
auth: token,
|
||||
request: { fetch: fetchWithTrustedCertificatesAsync },
|
||||
throttle: { enabled: false },
|
||||
userAgent,
|
||||
});
|
||||
|
||||
const getGithubReleaseAsync = async (
|
||||
baseUrl: string,
|
||||
identifier: string,
|
||||
versionRegex?: string,
|
||||
token?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
const parsed = parseOwnerName(identifier);
|
||||
if (!parsed)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/repo` },
|
||||
};
|
||||
|
||||
const api = getGithubApi(baseUrl, "Homarr-Lab/Homarr:GithubReleaseProvider", token);
|
||||
try {
|
||||
const allReleases = await api.paginate(api.rest.repos.listReleases, {
|
||||
owner: parsed.owner,
|
||||
repo: parsed.name,
|
||||
per_page: 100,
|
||||
});
|
||||
if (allReleases.length === 0) {
|
||||
return { success: false, error: { code: "noReleasesFound", message: `${identifier} has no GitHub releases` } };
|
||||
}
|
||||
|
||||
const releases = allReleases.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 result = getLatestRelease(releases, versionRegex);
|
||||
if ("noMatch" in result) {
|
||||
return { success: false, error: { code: "noMatchingVersion", message: result.noMatch } };
|
||||
}
|
||||
|
||||
const details = await getGithubDetailsAsync(api, parsed.owner, parsed.name);
|
||||
return { success: true, data: { ...details, ...result.release } };
|
||||
} 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,
|
||||
token?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
const parsed = parseOwnerPackageName(identifier);
|
||||
if (!parsed)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/package` },
|
||||
};
|
||||
|
||||
try {
|
||||
const repositoryName = `${parsed.owner}/${parsed.name}`;
|
||||
const regex = compileVersionRegex(versionRegex);
|
||||
if (versionRegex && !regex) {
|
||||
return { success: false, error: { code: "noMatchingVersion", message: `Invalid regex: /${versionRegex}/` } };
|
||||
}
|
||||
const registryUrl = new URL(baseUrl);
|
||||
const registryToken = token ?? (await getGitHubContainerRegistryTokenAsync(registryUrl, repositoryName));
|
||||
const tags = await getGitHubContainerRegistryTagsAsync(registryUrl, repositoryName, registryToken);
|
||||
if (tags.length === 0) {
|
||||
return { success: false, error: { code: "noReleasesFound", message: `${repositoryName} has no container tags` } };
|
||||
}
|
||||
const matchingTags = regex ? tags.filter((tag) => regex.test(tag)) : tags;
|
||||
const tagsToCheck = versionRegex
|
||||
? matchingTags.slice(-100)
|
||||
: matchingTags.includes("latest")
|
||||
? ["latest"]
|
||||
: matchingTags.slice(-100);
|
||||
if (tagsToCheck.length === 0) {
|
||||
const sampleTags = tags.slice(0, 5).join(", ");
|
||||
const msg = versionRegex
|
||||
? `Regex /${versionRegex}/ matched 0/${tags.length} tags. Sample: [${sampleTags}]`
|
||||
: `No "latest" tag found among ${tags.length} tags. Sample: [${sampleTags}]`;
|
||||
return { success: false, error: { code: "noMatchingVersion", message: msg } };
|
||||
}
|
||||
|
||||
const releases = await Promise.all(
|
||||
tagsToCheck.map(async (tag) => ({
|
||||
latestRelease: tag,
|
||||
latestReleaseAt: await getGitHubContainerRegistryTagCreatedAtAsync(
|
||||
registryUrl,
|
||||
repositoryName,
|
||||
tag,
|
||||
registryToken,
|
||||
),
|
||||
releaseUrl: `https://github.com/${repositoryName}/pkgs/container/${encodeURIComponent(parsed.name)}`,
|
||||
})),
|
||||
);
|
||||
const result = getLatestRelease(releases, versionRegex);
|
||||
if ("noMatch" in result) {
|
||||
return { success: false, error: { code: "noMatchingVersion", message: result.noMatch } };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
projectUrl: `https://github.com/${repositoryName}`,
|
||||
...result.release,
|
||||
},
|
||||
};
|
||||
} 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 fetchProvider(
|
||||
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 fetchProvider(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 fetchProvider(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 fetchProvider(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,
|
||||
token?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
const parsed = identifier.includes("/") ? parseOwnerName(identifier) : { owner: "", name: identifier };
|
||||
if (!parsed) return { success: false, error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}"` } };
|
||||
|
||||
const relativeUrl = parsed.owner
|
||||
? `/v2/namespaces/${encodeURIComponent(parsed.owner)}/repositories/${encodeURIComponent(parsed.name)}`
|
||||
: `/v2/repositories/library/${encodeURIComponent(parsed.name)}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
let lastNoMatch = "";
|
||||
for (let page = 0; page <= 5; page++) {
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `${relativeUrl}/tags?page_size=100&page=${page}` as `/${string}`),
|
||||
headers.Authorization ? { headers } : undefined,
|
||||
);
|
||||
if (!response.ok)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "unexpected", message: `Docker Hub ${response.status}: ${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 matched = getLatestRelease(result.data.results, versionRegex);
|
||||
if ("noMatch" in matched) {
|
||||
lastNoMatch = matched.noMatch;
|
||||
continue;
|
||||
}
|
||||
|
||||
const details = await getDockerHubDetailsAsync(baseUrl, relativeUrl as `/${string}`, token);
|
||||
return { success: true, data: { ...details, ...matched.release } };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "noMatchingVersion", message: lastNoMatch || `No matching tags found for ${identifier}` },
|
||||
};
|
||||
};
|
||||
|
||||
const getDockerHubDetailsAsync = async (
|
||||
baseUrl: string,
|
||||
relativeUrl: `/${string}`,
|
||||
token?: string,
|
||||
): Promise<DetailsProviderResponse> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `${relativeUrl}/` as `/${string}`),
|
||||
headers.Authorization ? { headers } : undefined,
|
||||
);
|
||||
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,
|
||||
token?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
const encodedIdentifier = encodeURIComponent(identifier);
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["PRIVATE-TOKEN"] = token;
|
||||
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}/releases?per_page=100`),
|
||||
headers["PRIVATE-TOKEN"] ? { headers } : undefined,
|
||||
);
|
||||
if (!response.ok)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "unexpected", message: `GitLab ${response.status}: ${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", message: `${identifier} has no GitLab releases` } };
|
||||
|
||||
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.tag_name,
|
||||
latestReleaseAt: releaseDate,
|
||||
releaseUrl: release._links?.self,
|
||||
releaseDescription: release.description ?? undefined,
|
||||
isPreRelease: releaseDate > new Date(),
|
||||
});
|
||||
return acc;
|
||||
}, []);
|
||||
const matched = getLatestRelease(releases, versionRegex);
|
||||
if ("noMatch" in matched) return { success: false, error: { code: "noMatchingVersion", message: matched.noMatch } };
|
||||
|
||||
const details = await getGitlabDetailsAsync(baseUrl, encodedIdentifier, token);
|
||||
return { success: true, data: { ...details, ...matched.release } };
|
||||
};
|
||||
|
||||
const getGitlabDetailsAsync = async (
|
||||
baseUrl: string,
|
||||
encodedIdentifier: string,
|
||||
token?: string,
|
||||
): Promise<DetailsProviderResponse> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers["PRIVATE-TOKEN"] = token;
|
||||
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `/api/v4/projects/${encodedIdentifier}`),
|
||||
headers["PRIVATE-TOKEN"] ? { headers } : undefined,
|
||||
);
|
||||
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 npmTimeMetadataKeys = new Set(["created", "modified"]);
|
||||
|
||||
const npmReleasesSchema = z.object({
|
||||
time: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.string().transform((value) => new Date(value)),
|
||||
)
|
||||
.transform((entries) =>
|
||||
Object.entries(entries)
|
||||
.filter(([key]) => !npmTimeMetadataKeys.has(key))
|
||||
.map(([key, value]) => ({ latestRelease: key, latestReleaseAt: value })),
|
||||
),
|
||||
versions: z.record(z.string(), z.object({ description: z.string().optional() })),
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
const getNpmReleaseAsync = async (
|
||||
baseUrl: string,
|
||||
identifier: string,
|
||||
versionRegex?: string,
|
||||
token?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
if (!identifier) return { success: false, error: { code: "invalidIdentifier", message: "Empty npm package name" } };
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `/${encodeURIComponent(identifier)}`),
|
||||
headers.Authorization ? { headers } : undefined,
|
||||
);
|
||||
if (!response.ok)
|
||||
return { success: false, error: { code: "unexpected", message: `npm ${response.status}: ${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 matched = getLatestRelease(releases, versionRegex);
|
||||
if ("noMatch" in matched) return { success: false, error: { code: "noMatchingVersion", message: matched.noMatch } };
|
||||
|
||||
return { success: true, data: matched.release };
|
||||
};
|
||||
|
||||
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,
|
||||
token?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
const parsed = parseOwnerName(identifier);
|
||||
if (!parsed)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/repo` },
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `token ${token}`;
|
||||
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `/api/v1/repos/${encodeURIComponent(parsed.owner)}/${encodeURIComponent(parsed.name)}/releases`),
|
||||
headers.Authorization ? { headers } : undefined,
|
||||
);
|
||||
if (!response.ok)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "unexpected", message: `Codeberg ${response.status}: ${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 matched = getLatestRelease(releases, versionRegex);
|
||||
if ("noMatch" in matched) return { success: false, error: { code: "noMatchingVersion", message: matched.noMatch } };
|
||||
|
||||
const details = await getCodebergDetailsAsync(baseUrl, parsed.owner, parsed.name, token);
|
||||
return { success: true, data: { ...details, ...matched.release } };
|
||||
};
|
||||
|
||||
const getCodebergDetailsAsync = async (
|
||||
baseUrl: string,
|
||||
owner: string,
|
||||
name: string,
|
||||
token?: string,
|
||||
): Promise<DetailsProviderResponse> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `token ${token}`;
|
||||
|
||||
const response = await fetchProvider(
|
||||
buildUrl(baseUrl, `/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}`),
|
||||
headers.Authorization ? { headers } : undefined,
|
||||
);
|
||||
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,
|
||||
versionRegex?: string,
|
||||
): Promise<ReleaseResponse> => {
|
||||
const parsed = parseOwnerName(identifier);
|
||||
if (!parsed)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "invalidIdentifier", message: `Cannot parse "${identifier}" as owner/name` },
|
||||
};
|
||||
|
||||
const response = await fetchProvider(buildUrl(baseUrl, "/api/v1/images"));
|
||||
if (!response.ok)
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "unexpected", message: `LSIO ${response.status}: ${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: "noReleasesFound", message: `Image "${parsed.name}" not found in LinuxServer.io registry` },
|
||||
};
|
||||
|
||||
const regex = compileVersionRegex(versionRegex);
|
||||
if (versionRegex && !regex) {
|
||||
return {
|
||||
success: false,
|
||||
error: { code: "noMatchingVersion", message: `Invalid regex: /${versionRegex}/` },
|
||||
};
|
||||
}
|
||||
|
||||
if (regex && !regex.test(release.version)) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "noMatchingVersion",
|
||||
message: `Regex /${versionRegex}/ does not match LSIO version "${release.version}"`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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", message: `Cannot parse "${identifier}" as owner/repo` },
|
||||
};
|
||||
|
||||
const response = await fetchProvider(
|
||||
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: `Quay ${response.status}: ${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 matched = getLatestRelease(releases, versionRegex);
|
||||
if ("noMatch" in matched) return { success: false, error: { code: "noMatchingVersion", message: matched.noMatch } };
|
||||
|
||||
return { success: true, data: { projectDescription: result.data.description, ...matched.release } };
|
||||
};
|
||||
@@ -1,16 +1,10 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import type { ReleaseResponse, ReleasesRepository } from "@homarr/integrations";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { ReleaseResponse, ReleasesRepositoryRequest } from "./release-providers";
|
||||
import { getLatestMatchingReleaseAsync } from "./release-providers";
|
||||
import { createRequestHandler } from "./lib/request-handler";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
export const releasesRequestHandler = createIntegrationRequestHandler<
|
||||
ReleaseResponse,
|
||||
IntegrationKindByCategory<"releasesProvider">,
|
||||
ReleasesRepository
|
||||
>({
|
||||
requestAsync: async (integration, input) => {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
return instance.getLatestMatchingReleaseAsync(input.identifier, input.versionRegex);
|
||||
export const releasesRequestHandler = createRequestHandler<ReleaseResponse, ReleasesRepositoryRequest>({
|
||||
async requestAsync(input) {
|
||||
return await getLatestMatchingReleaseAsync(input);
|
||||
},
|
||||
cacheTtlMs: 5 * 60 * 1000,
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
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: expect.objectContaining({ 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, "\\$&");
|
||||
@@ -1,30 +1,148 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { Station, Timetable } from "@homarr/integrations/types";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
export const timetableSearchStationsRequestHandler = createIntegrationRequestHandler<
|
||||
Station[],
|
||||
IntegrationKindByCategory<"timetable">,
|
||||
{ query: string }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.searchStationsAsync(input.query);
|
||||
import { createRequestHandler } from "./lib/request-handler";
|
||||
|
||||
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 = createRequestHandler<Station[], { baseUrl: string; query: string }>({
|
||||
async requestAsync(input) {
|
||||
return await searchStationsAsync(input.baseUrl, input.query);
|
||||
},
|
||||
cacheTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
export const timetableGetTimetableRequestHandler = createIntegrationRequestHandler<
|
||||
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,
|
||||
});
|
||||
export const timetableGetTimetableRequestHandler = createRequestHandler<Timetable, { baseUrl: string; stationId: string; limit: number }>({
|
||||
async requestAsync(input) {
|
||||
return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit });
|
||||
},
|
||||
cacheTtlMs: 60_000,
|
||||
});
|
||||
|
||||
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 = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Zurich",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(now);
|
||||
const time = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "Europe/Zurich",
|
||||
hour: "2-digit",
|
||||
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);
|
||||
|
||||
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)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const searchSchema = z.array(
|
||||
z.object({
|
||||
id: z.string().optional(),
|
||||
label: z.string(),
|
||||
iconclass: z.string(),
|
||||
}),
|
||||
);
|
||||
Reference in new issue
Block a user