forked from
tokono.ma/diffuse
A music player that connects to your cloud/distributed storage.
1import {
2 canParse,
3 greaterThan,
4 parse as parseSemver,
5 satisfies,
6 tryParseRange,
7} from "@std/semver";
8
9/**
10 * Given the current URL segment and the latest known artifact, returns whether
11 * the user is already on the latest version.
12 *
13 * @param {string} versionOrCid - The first path segment of the current URL
14 * @param {{ cid: string, version: string } | null} lastArtifact - The latest artifact
15 * @returns {boolean}
16 *
17 * @example No artifact means always latest
18 * ```js
19 * import { checkIsLatest } from "~/common/pages/version-upgrade.js";
20 *
21 * if (!checkIsLatest("4.0.0", null)) throw new Error("no artifact should be latest");
22 * ```
23 *
24 * @example CID comparison
25 * ```js
26 * import { checkIsLatest } from "~/common/pages/version-upgrade.js";
27 *
28 * const artifact = { cid: "bafyabc", version: "4.0.0" };
29 * if (!checkIsLatest("bafyabc", artifact)) throw new Error("matching CID should be latest");
30 * if (checkIsLatest("bafyxyz", artifact)) throw new Error("different CID should not be latest");
31 * ```
32 *
33 * @example Exact version comparison
34 * ```js
35 * import { checkIsLatest } from "~/common/pages/version-upgrade.js";
36 *
37 * const artifact = { cid: "bafyabc", version: "4.0.0" };
38 * if (!checkIsLatest("4.0.0", artifact)) throw new Error("matching version should be latest");
39 * if (checkIsLatest("3.9.0", artifact)) throw new Error("older version should not be latest");
40 * ```
41 *
42 * @example Version range (e.g. 4.0.x)
43 * ```js
44 * import { checkIsLatest } from "~/common/pages/version-upgrade.js";
45 *
46 * const artifact = { cid: "bafyabc", version: "4.0.5" };
47 * if (!checkIsLatest("4.0.x", artifact)) throw new Error("latest within range should be latest");
48 * if (checkIsLatest("3.x", artifact)) throw new Error("latest outside range should not be latest");
49 * ```
50 *
51 * @example Caret and tilde ranges
52 * ```js
53 * import { checkIsLatest } from "~/common/pages/version-upgrade.js";
54 *
55 * const artifact = { cid: "bafyabc", version: "4.1.0" };
56 * if (!checkIsLatest("^4.0.1", artifact)) throw new Error("^4.0.1 should match 4.1.0");
57 * if (checkIsLatest("~4.0.1", artifact)) throw new Error("~4.0.1 should not match 4.1.0");
58 * ```
59 *
60 * @example Partial versions are filled in with zeros (>=4 is equivalent to >=4.0.0)
61 * ```js
62 * import { checkIsLatest } from "~/common/pages/version-upgrade.js";
63 *
64 * const artifact = { cid: "bafyabc", version: "4.1.0" };
65 * if (!checkIsLatest(">=4", artifact)) throw new Error(">=4 should match 4.1.0");
66 * if (checkIsLatest(">=5", artifact)) throw new Error(">=5 should not match 4.1.0");
67 * ```
68 *
69 * @example Non-semver, non-range slugs are always latest
70 * ```js
71 * import { checkIsLatest } from "~/common/pages/version-upgrade.js";
72 *
73 * const artifact = { cid: "bafyabc", version: "4.0.0" };
74 * if (!checkIsLatest("some-branch", artifact)) throw new Error("non-semver slug should be latest");
75 * ```
76 */
77export function checkIsLatest(versionOrCid, lastArtifact) {
78 if (!lastArtifact) return true;
79 const usesCid = versionOrCid.startsWith("bafy");
80 if (usesCid) return versionOrCid === lastArtifact.cid;
81 if (canParse(versionOrCid)) return versionOrCid === lastArtifact.version;
82 const versionRange = tryParseRange(versionOrCid);
83 if (versionRange) {
84 return satisfies(parseSemver(lastArtifact.version), versionRange);
85 }
86 return true;
87}
88
89/**
90 * @param {Record<string, { version: string, cid: string }>} artifacts
91 * @param {{ includePrerelease?: boolean }} [options]
92 * @returns {{ version: string, cid: string } | null}
93 *
94 * @example Returns null for an empty artifact list
95 * ```js
96 * import { getLatestArtifact } from "~/common/pages/version-upgrade.js";
97 *
98 * if (getLatestArtifact({}) !== null) throw new Error("empty artifacts should return null");
99 * ```
100 *
101 * @example Returns the highest semver artifact
102 * ```js
103 * import { getLatestArtifact } from "~/common/pages/version-upgrade.js";
104 *
105 * const artifacts = {
106 * a: { cid: "a", version: "4.0.0" },
107 * b: { cid: "b", version: "4.1.0" },
108 * c: { cid: "c", version: "3.9.0" },
109 * };
110 * if (getLatestArtifact(artifacts)?.cid !== "b") throw new Error("should return highest version");
111 * ```
112 *
113 * @example Ignores non-semver versions
114 * ```js
115 * import { getLatestArtifact } from "~/common/pages/version-upgrade.js";
116 *
117 * const artifacts = {
118 * a: { cid: "a", version: "4.0.0" },
119 * b: { cid: "b", version: "some-branch" },
120 * };
121 * if (getLatestArtifact(artifacts)?.cid !== "a") throw new Error("should ignore non-semver versions");
122 * ```
123 *
124 * @example Excludes prerelease artifacts when includePrerelease is false
125 * ```js
126 * import { getLatestArtifact } from "~/common/pages/version-upgrade.js";
127 *
128 * const artifacts = {
129 * a: { cid: "a", version: "4.1.0" },
130 * b: { cid: "b", version: "4.2.0-nightly.1" },
131 * };
132 * if (getLatestArtifact(artifacts, { includePrerelease: false })?.cid !== "a") {
133 * throw new Error("should exclude prerelease artifacts");
134 * }
135 * if (getLatestArtifact(artifacts, { includePrerelease: true })?.cid !== "b") {
136 * throw new Error("should include prerelease artifacts when opted in");
137 * }
138 * ```
139 */
140export function getLatestArtifact(artifacts, { includePrerelease = true } = {}) {
141 return Object.values(artifacts).reduce(
142 /** @param {{ version: string, cid: string } | null} max */
143 (max, artifact) => {
144 if (!canParse(artifact.version)) return max;
145 if (!includePrerelease && parseSemver(artifact.version).prerelease?.length) return max;
146 if (!max) return artifact;
147 return greaterThan(parseSemver(artifact.version), parseSemver(max.version))
148 ? artifact
149 : max;
150 },
151 /** @type {{ version: string, cid: string } | null} */ (null),
152 );
153}
154
155/** @param {Element} status */
156function removeLoadingAnimation(status) {
157 status.querySelectorAll(".ph-spinner").forEach((icon) => {
158 icon.parentElement?.classList.add("hidden");
159
160 setTimeout(() => {
161 icon.parentElement?.classList.remove("animate-spin");
162 icon.classList.remove("ph-spinner");
163 icon.classList.add("ph-arrow-fat-lines-up");
164 }, 500);
165 });
166}
167
168/**
169 * @param {Element} status
170 * @param {{ usesCid: boolean, isLatest: boolean }} options
171 */
172function updateUpgradeLink(status, { usesCid, isLatest }) {
173 status.querySelectorAll(`[href="/latest/"]`).forEach((a) => {
174 if (usesCid) a.setAttribute("href", "/latest/hash/");
175 if (!isLatest) setTimeout(() => a.classList.remove("hidden"), 750);
176 });
177}
178
179/**
180 * Setup version upgrade (only works with `diffuse-artifacts` deployments)
181 */
182export async function versionUpgrade() {
183 const isDiffuseDomain = document.location.hostname.endsWith("diffuse.sh");
184
185 if (!isDiffuseDomain) {
186 document.querySelectorAll("#status a").forEach((el) => {
187 el.classList.add("hidden");
188 });
189
190 return;
191 }
192
193 const versionOrCid =
194 document.location.pathname.slice(1).split("/")[0]?.toLowerCase() ?? "";
195 const usesCid = versionOrCid.startsWith("bafy");
196
197 const { default: artifacts } = await import(
198 `${document.location.origin}/artifacts.json`,
199 { with: { type: "json" } }
200 ).catch(() => ({ default: {} }));
201
202 const currentIsStable =
203 canParse(versionOrCid) && !parseSemver(versionOrCid).prerelease?.length;
204 const lastArtifact = getLatestArtifact(artifacts, {
205 includePrerelease: !currentIsStable,
206 });
207 const isLatest = checkIsLatest(versionOrCid, lastArtifact);
208
209 document.querySelectorAll("#status").forEach((status) => {
210 removeLoadingAnimation(status);
211 updateUpgradeLink(status, { usesCid, isLatest });
212 });
213}