Files
homarr/packages/request-handler/src/update-checker.ts
T

118 lines
3.3 KiB
TypeScript

import dayjs from "dayjs";
import { Octokit } from "octokit";
import { compareSemVer, isValidSemVer, parseSemVer } from "semver-parser";
import { env } from "@homarr/common/env";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createChannelWithLatestAndEvents } from "@homarr/redis";
import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
import packageJson from "../../../package.json";
const logger = createLogger({ module: "updateCheckerRequestHandler" });
export const updateCheckerRequestHandler = createCachedRequestHandler({
queryKey: "homarr-update-checker",
cacheDuration: dayjs.duration(1, "hour"),
async requestAsync(_) {
return {
availableUpdates: await getAvailableUpdatesAsync(packageJson.version),
};
},
createRedisChannel() {
return createChannelWithLatestAndEvents<{
availableUpdates: Update[];
}>("homarr:update");
},
});
interface Update {
name: string | null;
contentHtml?: string;
url: string;
tagName: string;
isPrerelease: boolean;
}
const isPrereleaseTag = (tagName: string) => {
try {
const parsed = parseSemVer(tagName);
return Boolean(parsed.pre?.length);
} catch {
return false;
}
};
export const getAvailableUpdatesAsync = async (currentVersion: string) => {
if (env.NO_EXTERNAL_CONNECTION) return [];
if (!isValidSemVer(currentVersion)) {
throw new ErrorWithMetadata("Unable to check for updates due to non semantic current version", {
currentVersion,
});
}
const octokit = new Octokit({
request: {
fetch: fetchWithTrustedCertificatesAsync,
},
});
const isCurrentPrerelease = isPrereleaseTag(currentVersion);
const releases = await octokit.rest.repos.listReleases({
owner: "homarr-labs",
repo: "homarr",
});
const { skippedTags, semanticReleases } = releases.data
.map((release) => ({
name: release.name,
contentHtml: release.body_html,
url: release.html_url,
tagName: release.tag_name,
isPrerelease: release.prerelease,
}))
.reduce(
(prev, curr) => {
if (!isValidSemVer(curr.tagName)) {
prev.skippedTags.push(curr.tagName);
return prev;
}
prev.semanticReleases.push(curr);
return prev;
},
{ semanticReleases: [] as Update[], skippedTags: [] as string[] },
);
if (skippedTags.length > 0) {
logger.warn(
"Some releases were skipped during the update check because their tag name is not a valid semantic version",
{
skippedTags: skippedTags.join(","),
},
);
}
const availableUpdates = semanticReleases
.filter((release) => isCurrentPrerelease || !release.isPrerelease)
.filter((release) => compareSemVer(release.tagName, currentVersion) > 0)
.sort((releaseA, releaseB) => compareSemVer(releaseB.tagName, releaseA.tagName));
if (availableUpdates.length === 0) {
logger.debug("No available updates found", { currentVersion });
return [];
}
logger.info("Found available updates", {
version: availableUpdates[0]?.tagName,
count: availableUpdates.length,
currentVersion,
});
return availableUpdates;
};