From ea2a1d3944f54932478b987dc78a5e1b360cd668 Mon Sep 17 00:00:00 2001 From: Beckett Frey Date: Wed, 19 Aug 2026 16:56:30 -0500 Subject: [PATCH 1/4] Render the download panel on the server DownloadButton fetched /api/releases from the browser on every visit, so each visitor cost a GitHub round trip, the panel flashed a loading state, and crawlers saw no version numbers at all. Resolve the data during render instead. lib/releases.ts holds the fetch and grouping logic lifted from the route handler; DownloadButton becomes an async server component that awaits it and hands the result to DownloadButtonClient for the interactive parts. /download drops "use client" so it can prerender, and gains its own metadata. Two details the client-side version did not have to worry about: - OS detection has no user agent to read on the server, so it moves to useSyncExternalStore and selectedOS becomes derived rather than stored. The server render and the hydration render are then identical by construction. (An effect would also trip react-hooks/set-state-in-effect.) - Dates are formatted through a fixed en-US/UTC Intl.DateTimeFormat. toLocaleDateString() resolves against the viewer's timezone, which would mismatch the prerendered HTML on hydration. getReleases() never throws: a GitHub outage must not fail the build, and on an already prerendered page a failed revalidation leaves the last good render in place. Deletes app/api/releases/route.ts, which existed only to serve this component and now has no callers. --- app/api/releases/route.ts | 130 -------------- app/download/page.tsx | 13 +- components/DownloadButton.tsx | 268 +++------------------------- components/DownloadButtonClient.tsx | 263 +++++++++++++++++++++++++++ lib/releases.ts | 156 ++++++++++++++++ 5 files changed, 451 insertions(+), 379 deletions(-) delete mode 100644 app/api/releases/route.ts create mode 100644 components/DownloadButtonClient.tsx create mode 100644 lib/releases.ts diff --git a/app/api/releases/route.ts b/app/api/releases/route.ts deleted file mode 100644 index 5ef663c..0000000 --- a/app/api/releases/route.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { NextResponse } from "next/server"; -import type { - GitHubRelease, - GitHubAsset, - GroupedReleases, - LatestRelease, - OperatingSystem, - ReleaseAsset, - ReleasesAPIResponse, -} from "../../../types/releases"; -import { fakeGitHubReleases } from "../../../lib/fakeReleases"; - -const OS_EXTENSIONS: Record = { - macos: /\.(dmg|pkg)$/i, - windows: /\.(exe|msi)$/i, - linux: /\.(AppImage|deb|rpm)$/i, -}; - -function detectAssetOS(name: string): OperatingSystem | null { - for (const os of Object.keys(OS_EXTENSIONS) as OperatingSystem[]) { - if (OS_EXTENSIONS[os].test(name)) return os; - } - return null; -} - -function parseVersion(tag: string): string | null { - const match = tag.match(/^v?(\d+\.\d+\.\d+)/); - return match ? match[1] : null; -} - -function compareVersions(v1: string, v2: string): number { - const parts1 = v1.split(".").map(Number); - const parts2 = v2.split(".").map(Number); - for (let i = 0; i < 3; i++) { - if (parts1[i] > parts2[i]) return 1; - if (parts1[i] < parts2[i]) return -1; - } - return 0; -} - -function toReleaseAsset(asset: GitHubAsset): ReleaseAsset { - return { - name: asset.name, - url: asset.browser_download_url, - size: asset.size, - }; -} - -export async function GET() { - try { - let releases: GitHubRelease[]; - - if (process.env.USE_FAKE_RELEASES === "true") { - releases = fakeGitHubReleases; - } else { - const owner = process.env.GITHUB_OWNER; - const repo = process.env.GITHUB_REPO; - - if (!owner || !repo) { - return NextResponse.json( - { error: "GITHUB_OWNER or GITHUB_REPO is not configured" }, - { status: 500 }, - ); - } - - const response = await fetch( - `https://api.github.com/repos/${owner}/${repo}/releases`, - { - headers: { Accept: "application/vnd.github+json" }, - next: { revalidate: 5000 }, - }, - ); - - if (!response.ok) { - const errorText = await response.text(); - console.error("GitHub API error:", response.status, errorText); - return NextResponse.json( - { error: `Failed to fetch releases from GitHub: ${response.status}` }, - { status: response.status }, - ); - } - - releases = await response.json(); - } - - const grouped: GroupedReleases = {}; - - for (const release of releases) { - if (release.draft) continue; - const version = parseVersion(release.tag_name); - if (!version) continue; - - const byOS: Partial> = {}; - for (const asset of release.assets) { - const os = detectAssetOS(asset.name); - if (!os) continue; - (byOS[os] ??= []).push(toReleaseAsset(asset)); - } - - for (const os of Object.keys(byOS) as OperatingSystem[]) { - const assets = byOS[os]!; - const current = grouped[os]; - if (!current || compareVersions(version, current.version) > 0) { - const next: LatestRelease = { - version, - tag: release.tag_name, - publishedAt: release.published_at, - htmlUrl: release.html_url, - prerelease: release.prerelease, - assets, - }; - grouped[os] = next; - } - } - } - - const apiResponse: ReleasesAPIResponse = { - releases: grouped, - lastUpdated: new Date().toISOString(), - }; - - return NextResponse.json(apiResponse); - } catch (error) { - console.error("Error fetching releases:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 }, - ); - } -} diff --git a/app/download/page.tsx b/app/download/page.tsx index 927162e..821e415 100644 --- a/app/download/page.tsx +++ b/app/download/page.tsx @@ -1,7 +1,18 @@ -"use client"; +import type { Metadata } from "next"; import { Footer, Navbar } from "../../layout"; import DownloadButton from "../../components/DownloadButton"; +export const metadata: Metadata = { + title: "Download VoxKit", + description: + "Download the latest VoxKit release for macOS, Windows, or Linux. Built for speech pathology researchers, no command line required.", +}; + +// Fallback only: the nightly cron at /api/revalidate-releases is what normally +// refreshes this page. Keep in sync with RELEASES_REVALIDATE_SECONDS (24h) -- +// Next requires a literal here, so it cannot be imported. +export const revalidate = 86400; + export default function DownloadPage() { return (
diff --git a/components/DownloadButton.tsx b/components/DownloadButton.tsx index 20d53e2..7ee06a4 100644 --- a/components/DownloadButton.tsx +++ b/components/DownloadButton.tsx @@ -1,257 +1,29 @@ -import { useEffect, useState } from "react"; -import { Download, AlertTriangle } from "lucide-react"; -import { AppleIcon, WindowsIcon, LinuxIcon } from "./OSIcons"; -import { PRE_RELEASE_DISCLAIMER } from "../lib/fakeReleases"; -import GridButton from "./GridButton"; -import type { - ReleaseAsset, - ReleasesAPIResponse, - OperatingSystem, - LatestRelease, -} from "../types/releases"; - -type OS = OperatingSystem | null; - -const detectOS = (): OS => { - if (typeof window === "undefined") return null; - const userAgent = window.navigator.userAgent.toLowerCase(); - if (userAgent.includes("win")) return "windows"; - if (userAgent.includes("mac")) return "macos"; - if (userAgent.includes("linux")) return "linux"; - return null; -}; - -const isPreReleaseVersion = (version: string): boolean => { - const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/); - if (!match) return false; - const major = parseInt(match[1], 10); - return major < 1; -}; - -export default function DownloadButton() { - const [releasesData, setReleasesData] = useState( - null, - ); - const [detectedOS] = useState(detectOS); - const [selectedOS, setSelectedOS] = useState(detectOS); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - fetch("/api/releases") - .then((res) => { - if (!res.ok) throw new Error("Failed to fetch releases"); - return res.json(); - }) - .then((data: ReleasesAPIResponse) => { - setReleasesData(data); - setLoading(false); - }) - .catch((err) => { - console.error(err); - setError("Unable to load release information"); - setLoading(false); - }); - }, []); - - const getReleaseForOS = (): LatestRelease | undefined => { - if (!releasesData?.releases || !selectedOS) return undefined; - return releasesData.releases[selectedOS]; - }; - - const selectBestAsset = ( - assets: ReleaseAsset[], - os: OS, - ): ReleaseAsset | null => { - if (!assets || assets.length === 0) return null; - if (assets.length === 1) return assets[0]; - - const priorities: Record = { - macos: [/\.dmg$/i, /\.pkg$/i], - windows: [/\.exe$/i, /\.msi$/i], - linux: [/\.AppImage$/i, /\.deb$/i, /\.rpm$/i], - }; - - if (!os || !priorities[os]) return assets[0]; - - for (const pattern of priorities[os]) { - const match = assets.find((asset) => pattern.test(asset.name)); - if (match) return match; - } - - return assets[0]; - }; - - const handleDownload = (asset: ReleaseAsset) => { - if (asset?.url) { - window.open(asset.url, "_blank"); - } - }; - - const formatFileSize = (bytes: number) => { - const mb = bytes / (1024 * 1024); - return `${mb.toFixed(1)} MB`; - }; - - const getOSDisplayName = (os: OS) => { - if (!os) return ""; - return os === "macos" ? "macOS" : os === "windows" ? "Windows" : "Linux"; - }; - - if (loading) { - return ( -
-
- Loading release information... -
-
- ); - } - - if (error || !releasesData) { +import { getReleases } from "../lib/releases"; +import DownloadButtonClient from "./DownloadButtonClient"; + +/** + * Server component. Resolves release data during render so the panel arrives + * fully populated in the initial HTML: no client fetch, no loading flash, and + * the version numbers are visible to crawlers. + * + * Freshness is controlled by the page's `revalidate` plus the nightly cron in + * `app/api/revalidate-releases/route.ts`, not by this component. + */ +export default async function DownloadButton() { + const result = await getReleases(); + + if (!result.ok) { return (
-
- {error || "No release data available"} -
+
{result.error}
); } - const currentRelease = getReleaseForOS(); - const availableOSes = Object.keys(releasesData.releases); - const hasPreReleaseVersion = - !!currentRelease && - (currentRelease.prerelease || isPreReleaseVersion(currentRelease.version)); - const bestAsset = currentRelease - ? selectBestAsset(currentRelease.assets, selectedOS) - : null; - return ( - <> - {hasPreReleaseVersion && ( -
- -

{PRE_RELEASE_DISCLAIMER}

-
- )} - -
-

- - Download VoxKit -

-

- Select your operating system -

- - {/* OS Selection */} -
- {(["windows", "macos", "linux"] as const).map((os) => { - const isSelected = selectedOS === os; - const isDetected = detectedOS === os; - const isAvailable = availableOSes.includes(os); - - return ( - setSelectedOS(os)} - disabled={!isAvailable} - className={` - px-6 py-4 text-sm font-medium transition-all - ${isAvailable ? "" : "opacity-50 cursor-not-allowed"} - ${ - isSelected - ? "bg-gradient-to-r from-blue-600 to-blue-700 text-white border border-blue-400/50" - : "bg-slate-700/50 text-slate-300 border border-slate-600" - } - `} - > -
-
- {os === "windows" && } - {os === "macos" && } - {os === "linux" && } -
-
{getOSDisplayName(os)}
- {isDetected && ( -
Detected
- )} - {!isAvailable && ( -
- Not Available -
- )} -
-
- ); - })} -
- - {/* Release Download */} - {currentRelease ? ( -
-
-
-
- {getOSDisplayName(selectedOS)} • v{currentRelease.version} -
-

- Released{" "} - {new Date(currentRelease.publishedAt).toLocaleDateString()} -

- {bestAsset && ( -

- {bestAsset.name} • {formatFileSize(bestAsset.size)} -

- )} -
-
- {bestAsset ? ( - handleDownload(bestAsset)} - className="text-small px-8 py-4 rounded-lg bg-gradient-to-r from-blue-500 to-blue-600 text-white border-white" - rippleColor="rgba(255, 255, 255, 0.5)" - > - - Download - - ) : ( - - No assets - - )} - window.open(currentRelease.htmlUrl, "_blank")} - className="text-small px-8 py-4 rounded-lg text-cyan-400 border-cyan-400" - > - View Release - -
-
-
- ) : ( -
- No release available for {getOSDisplayName(selectedOS) || "this OS"} - . -
- )} - - {/* Footer */} -
-

- Last updated: {new Date(releasesData.lastUpdated).toLocaleString()} -

- - View all releases on GitHub - -
-
- + ); } diff --git a/components/DownloadButtonClient.tsx b/components/DownloadButtonClient.tsx new file mode 100644 index 0000000..7e058ac --- /dev/null +++ b/components/DownloadButtonClient.tsx @@ -0,0 +1,263 @@ +"use client"; +import { useState, useSyncExternalStore } from "react"; +import { Download, AlertTriangle } from "lucide-react"; +import { AppleIcon, WindowsIcon, LinuxIcon } from "./OSIcons"; +import { PRE_RELEASE_DISCLAIMER } from "../lib/fakeReleases"; +import GridButton from "./GridButton"; +import type { + ReleaseAsset, + GroupedReleases, + OperatingSystem, + LatestRelease, +} from "../types/releases"; + +type OS = OperatingSystem | null; + +type DownloadButtonClientProps = { + releases: GroupedReleases; + /** ISO timestamp of the server render that produced `releases`. */ + lastUpdated: string; +}; + +/** Display order, and the tie-break order for the server-rendered default. */ +const OS_ORDER = ["windows", "macos", "linux"] as const; + +// Dates are formatted in a fixed locale and timezone so the server render and +// the hydrated client render agree. `toLocaleDateString()` would resolve +// against the visitor's timezone and mismatch the prerendered HTML. +const RELEASE_DATE_FORMAT = new Intl.DateTimeFormat("en-US", { + timeZone: "UTC", + year: "numeric", + month: "short", + day: "numeric", +}); + +const LAST_UPDATED_FORMAT = new Intl.DateTimeFormat("en-US", { + timeZone: "UTC", + dateStyle: "medium", + timeStyle: "short", +}); + +const detectOS = (): OS => { + if (typeof window === "undefined") return null; + const userAgent = window.navigator.userAgent.toLowerCase(); + if (userAgent.includes("win")) return "windows"; + if (userAgent.includes("mac")) return "macos"; + if (userAgent.includes("linux")) return "linux"; + return null; +}; + +// There is no user agent to sniff during SSR, so detection is modeled as an +// external store: React renders `null` on the server and again while +// hydrating, then re-renders with the real value. The user agent never changes +// mid-session, so there is nothing to subscribe to. +const subscribeToOS = () => () => {}; +const getServerOS = (): OS => null; + +const isPreReleaseVersion = (version: string): boolean => { + const match = version.match(/^v?(\d+)\.(\d+)\.(\d+)/); + if (!match) return false; + const major = parseInt(match[1], 10); + return major < 1; +}; + +export default function DownloadButtonClient({ + releases, + lastUpdated, +}: DownloadButtonClientProps) { + const detectedOS = useSyncExternalStore(subscribeToOS, detectOS, getServerOS); + const [pickedOS, setPickedOS] = useState(null); + + // Derived rather than stored, so the server and the hydrated client agree: + // an explicit pick wins, then the detected OS if we ship builds for it, then + // the first OS with a release at all. + const selectedOS: OS = + pickedOS ?? + (detectedOS && releases[detectedOS] + ? detectedOS + : (OS_ORDER.find((os) => releases[os]) ?? null)); + + const getReleaseForOS = (): LatestRelease | undefined => { + if (!selectedOS) return undefined; + return releases[selectedOS]; + }; + + const selectBestAsset = ( + assets: ReleaseAsset[], + os: OS, + ): ReleaseAsset | null => { + if (!assets || assets.length === 0) return null; + if (assets.length === 1) return assets[0]; + + const priorities: Record = { + macos: [/\.dmg$/i, /\.pkg$/i], + windows: [/\.exe$/i, /\.msi$/i], + linux: [/\.AppImage$/i, /\.deb$/i, /\.rpm$/i], + }; + + if (!os || !priorities[os]) return assets[0]; + + for (const pattern of priorities[os]) { + const match = assets.find((asset) => pattern.test(asset.name)); + if (match) return match; + } + + return assets[0]; + }; + + const handleDownload = (asset: ReleaseAsset) => { + if (asset?.url) { + window.open(asset.url, "_blank"); + } + }; + + const formatFileSize = (bytes: number) => { + const mb = bytes / (1024 * 1024); + return `${mb.toFixed(1)} MB`; + }; + + const getOSDisplayName = (os: OS) => { + if (!os) return ""; + return os === "macos" ? "macOS" : os === "windows" ? "Windows" : "Linux"; + }; + + const currentRelease = getReleaseForOS(); + const availableOSes = Object.keys(releases); + const hasPreReleaseVersion = + !!currentRelease && + (currentRelease.prerelease || isPreReleaseVersion(currentRelease.version)); + const bestAsset = currentRelease + ? selectBestAsset(currentRelease.assets, selectedOS) + : null; + + return ( + <> + {hasPreReleaseVersion && ( +
+ +

{PRE_RELEASE_DISCLAIMER}

+
+ )} + +
+

+ + Download VoxKit +

+

+ Select your operating system +

+ + {/* OS Selection */} +
+ {OS_ORDER.map((os) => { + const isSelected = selectedOS === os; + const isDetected = detectedOS === os; + const isAvailable = availableOSes.includes(os); + + return ( + setPickedOS(os)} + disabled={!isAvailable} + className={` + px-6 py-4 text-sm font-medium transition-all + ${isAvailable ? "" : "opacity-50 cursor-not-allowed"} + ${ + isSelected + ? "bg-gradient-to-r from-blue-600 to-blue-700 text-white border border-blue-400/50" + : "bg-slate-700/50 text-slate-300 border border-slate-600" + } + `} + > +
+
+ {os === "windows" && } + {os === "macos" && } + {os === "linux" && } +
+
{getOSDisplayName(os)}
+ {isDetected && ( +
Detected
+ )} + {!isAvailable && ( +
+ Not Available +
+ )} +
+
+ ); + })} +
+ + {/* Release Download */} + {currentRelease ? ( +
+
+
+
+ {getOSDisplayName(selectedOS)} • v{currentRelease.version} +
+

+ Released{" "} + {RELEASE_DATE_FORMAT.format( + new Date(currentRelease.publishedAt), + )} +

+ {bestAsset && ( +

+ {bestAsset.name} • {formatFileSize(bestAsset.size)} +

+ )} +
+
+ {bestAsset ? ( + handleDownload(bestAsset)} + className="text-small px-8 py-4 rounded-lg bg-gradient-to-r from-blue-500 to-blue-600 text-white border-white" + rippleColor="rgba(255, 255, 255, 0.5)" + > + + Download + + ) : ( + + No assets + + )} + window.open(currentRelease.htmlUrl, "_blank")} + className="text-small px-8 py-4 rounded-lg text-cyan-400 border-cyan-400" + > + View Release + +
+
+
+ ) : ( +
+ No release available for {getOSDisplayName(selectedOS) || "this OS"} + . +
+ )} + + {/* Footer */} +
+

+ Last updated: {LAST_UPDATED_FORMAT.format(new Date(lastUpdated))}{" "} + UTC +

+ + View all releases on GitHub + +
+
+ + ); +} diff --git a/lib/releases.ts b/lib/releases.ts new file mode 100644 index 0000000..806d353 --- /dev/null +++ b/lib/releases.ts @@ -0,0 +1,156 @@ +import type { + GitHubAsset, + GitHubRelease, + GroupedReleases, + LatestRelease, + OperatingSystem, + ReleaseAsset, + ReleasesAPIResponse, +} from "../types/releases"; +import { fakeGitHubReleases } from "./fakeReleases"; + +/** + * Server-side resolution of the latest VoxKit release per OS. + * + * This runs during render (see `components/DownloadButton.tsx`), not in the + * browser, so a visitor never pays for a GitHub round trip and the download + * panel ships fully populated in the initial HTML. + */ + +/** Cache tag on the GitHub fetch. Purged nightly by `/api/revalidate-releases`. */ +export const RELEASES_CACHE_TAG = "releases"; + +/** + * Fallback TTL for the tag above. The nightly cron is what normally refreshes + * the data; this keeps the site from serving indefinitely stale releases if + * the cron ever stops firing. + */ +export const RELEASES_REVALIDATE_SECONDS = 60 * 60 * 24; + +const OS_EXTENSIONS: Record = { + macos: /\.(dmg|pkg)$/i, + windows: /\.(exe|msi)$/i, + linux: /\.(AppImage|deb|rpm)$/i, +}; + +function detectAssetOS(name: string): OperatingSystem | null { + for (const os of Object.keys(OS_EXTENSIONS) as OperatingSystem[]) { + if (OS_EXTENSIONS[os].test(name)) return os; + } + return null; +} + +function parseVersion(tag: string): string | null { + const match = tag.match(/^v?(\d+\.\d+\.\d+)/); + return match ? match[1] : null; +} + +function compareVersions(v1: string, v2: string): number { + const parts1 = v1.split(".").map(Number); + const parts2 = v2.split(".").map(Number); + for (let i = 0; i < 3; i++) { + if (parts1[i] > parts2[i]) return 1; + if (parts1[i] < parts2[i]) return -1; + } + return 0; +} + +function toReleaseAsset(asset: GitHubAsset): ReleaseAsset { + return { + name: asset.name, + url: asset.browser_download_url, + size: asset.size, + }; +} + +/** Reduces the full release list to the newest release carrying assets per OS. */ +export function groupReleasesByOS(releases: GitHubRelease[]): GroupedReleases { + const grouped: GroupedReleases = {}; + + for (const release of releases) { + if (release.draft) continue; + const version = parseVersion(release.tag_name); + if (!version) continue; + + const byOS: Partial> = {}; + for (const asset of release.assets) { + const os = detectAssetOS(asset.name); + if (!os) continue; + (byOS[os] ??= []).push(toReleaseAsset(asset)); + } + + for (const os of Object.keys(byOS) as OperatingSystem[]) { + const assets = byOS[os]!; + const current = grouped[os]; + if (!current || compareVersions(version, current.version) > 0) { + const next: LatestRelease = { + version, + tag: release.tag_name, + publishedAt: release.published_at, + htmlUrl: release.html_url, + prerelease: release.prerelease, + assets, + }; + grouped[os] = next; + } + } + } + + return grouped; +} + +export type ReleasesResult = + | { ok: true; data: ReleasesAPIResponse } + | { ok: false; error: string }; + +async function fetchGitHubReleases(): Promise { + if (process.env.USE_FAKE_RELEASES === "true") return fakeGitHubReleases; + + const owner = process.env.GITHUB_OWNER; + const repo = process.env.GITHUB_REPO; + + if (!owner || !repo) { + throw new Error("GITHUB_OWNER or GITHUB_REPO is not configured"); + } + + const response = await fetch( + `https://api.github.com/repos/${owner}/${repo}/releases`, + { + headers: { Accept: "application/vnd.github+json" }, + next: { + revalidate: RELEASES_REVALIDATE_SECONDS, + tags: [RELEASES_CACHE_TAG], + }, + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + console.error("GitHub API error:", response.status, errorText); + throw new Error(`Failed to fetch releases from GitHub: ${response.status}`); + } + + return response.json(); +} + +/** + * Never throws. A GitHub outage must not fail the build or blank the download + * page, so the failure is returned for the caller to render. On an already + * prerendered page, a failed background revalidation leaves the last good + * render in place and visitors see nothing at all. + */ +export async function getReleases(): Promise { + try { + const releases = await fetchGitHubReleases(); + + const data: ReleasesAPIResponse = { + releases: groupReleasesByOS(releases), + lastUpdated: new Date().toISOString(), + }; + + return { ok: true, data }; + } catch (error) { + console.error("Error fetching releases:", error); + return { ok: false, error: "Unable to load release information" }; + } +} From 1741d26a713c5cf72d91447a8acd8152cffcef7e Mon Sep 17 00:00:00 2001 From: Beckett Frey Date: Wed, 19 Aug 2026 16:56:50 -0500 Subject: [PATCH 2/4] Refresh release data nightly via a Vercel cron The server render needs something to invalidate it, and issue #3 asks for a once-daily refresh at midnight so a new release gets a grace period before the website advertises it. vercel.json crons /api/revalidate-releases at 00:00 UTC. The handler purges the "releases" cache tag and the prerendered /download page, so the first visitor after midnight triggers exactly one upstream fetch. That puts the site at roughly one GitHub request per day rather than one per visitor, well inside the 60/hour unauthenticated limit. The endpoint requires CRON_SECRET, which Vercel sends as a bearer token on cron invocations. It 500s when the variable is missing rather than running unauthenticated, so a misconfigured deploy fails loudly. --- app/api/revalidate-releases/route.ts | 37 ++++++++++++++++++++++++++++ vercel.json | 9 +++++++ 2 files changed, 46 insertions(+) create mode 100644 app/api/revalidate-releases/route.ts create mode 100644 vercel.json diff --git a/app/api/revalidate-releases/route.ts b/app/api/revalidate-releases/route.ts new file mode 100644 index 0000000..9ef73f8 --- /dev/null +++ b/app/api/revalidate-releases/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { revalidatePath, revalidateTag } from "next/cache"; +import { RELEASES_CACHE_TAG } from "../../../lib/releases"; + +/** + * Cron target, wired to midnight UTC daily in `vercel.json`. + * + * Drops both the cached GitHub response and the prerendered download page, so + * the first visitor after midnight triggers one fresh fetch. New releases are + * therefore picked up with up to a day's delay — a deliberate grace period in + * case a release turns out to be problematic. + * + * Vercel signs cron requests with `Authorization: Bearer $CRON_SECRET`. + */ +export async function GET(request: Request) { + const secret = process.env.CRON_SECRET; + + if (!secret) { + console.error("CRON_SECRET is not configured; refusing to revalidate."); + return NextResponse.json( + { error: "CRON_SECRET is not configured" }, + { status: 500 }, + ); + } + + if (request.headers.get("authorization") !== `Bearer ${secret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + revalidateTag(RELEASES_CACHE_TAG, "max"); + revalidatePath("/download"); + + return NextResponse.json({ + revalidated: true, + at: new Date().toISOString(), + }); +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..193969c --- /dev/null +++ b/vercel.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "crons": [ + { + "path": "/api/revalidate-releases", + "schedule": "0 0 * * *" + } + ] +} From 57ad8f5ec6ef29af8fb175db7bd25ceec1e5a8f8 Mon Sep 17 00:00:00 2001 From: Beckett Frey Date: Wed, 19 Aug 2026 16:57:08 -0500 Subject: [PATCH 3/4] Document release freshness and CRON_SECRET The up-to-a-day delay before a new release appears on the site is deliberate, but nothing in the repo said so, and a maintainer hitting it would reasonably read it as a bug. Records the grace period, the manual override for publishing immediately, and the fact that CRON_SECRET must exist in the Vercel dashboard before this deploys. Also notes that the page's revalidate literal duplicates RELEASES_REVALIDATE_SECONDS because Next cannot accept an import there, so the two have to move together. --- .github/CONTRIBUTING.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ab8ae1a..0031d45 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -41,8 +41,6 @@ branch-name ──PR──► main ──auto-deploy──► Vercel production | `npm run format` | Prettier-format the whole project. | | `npm run format:check` | Prettier check (no writes); fails on drift. | ---- - ## For repo & Vercel owners You own the guardrails that make the developer workflow safe. The rules below are authoritative. @@ -93,8 +91,35 @@ Also in `Settings → General → Pull Requests`: - **Environment variables**: managed in the Vercel dashboard. - Production secrets must be scoped **Production only** so PR previews can't read them. - Preview-safe variables can be scoped to Preview + Development. + - `CRON_SECRET` is required (see [Release data freshness](#release-data-freshness)). Vercel sends it as `Authorization: Bearer $CRON_SECRET` on cron invocations. Generate a random value; never commit it. - **GitHub integration**: the Vercel GitHub App must have access to this repo so it can post deployment statuses (these become required checks in branch protection). +### Release data freshness + +`/download` is **prerendered**, not fetched in the browser. `components/DownloadButton.tsx` is a server component that resolves GitHub releases at render time via `lib/releases.ts`, then hands the data to `DownloadButtonClient.tsx` for the interactive bits. Visitors never call GitHub, so the page costs one upstream request per day instead of one per visitor — comfortably under GitHub's 60/hour unauthenticated limit. + +Refresh is driven by the cron in `vercel.json`: + +``` +0 0 * * * → /api/revalidate-releases +``` + +At midnight UTC it purges the `releases` cache tag and the prerendered `/download` page; the next visitor triggers one fresh fetch. **A new release therefore takes up to a day to appear on the site.** That delay is intentional — a grace period to pull a release that turns out to be problematic before the website advertises it. + +Two safety nets: + +- `export const revalidate` on `app/download/page.tsx` (24h) refreshes the page even if the cron stops firing. It duplicates `RELEASES_REVALIDATE_SECONDS` in `lib/releases.ts` because Next requires a literal there — change both together. +- If GitHub is down when a refresh runs, the last good render keeps being served; visitors see nothing wrong. + +To publish a release immediately, either redeploy or call the endpoint by hand: + +```bash +curl -H "Authorization: Bearer $CRON_SECRET" https:///api/revalidate-releases +``` + +> [!NOTE] +> Vercel cron schedules are UTC, and on Hobby plans they run at most once a day and fire within the hour of the scheduled time. Both are fine for a daily grace period. + ### Rollback Production is `main`. Two paths: From 23d7c6877dbf96221f3990db6436858f3a5eb3ca Mon Sep 17 00:00:00 2001 From: Beckett Frey Date: Wed, 19 Aug 2026 16:58:12 -0500 Subject: [PATCH 4/4] Put Windows in the middle download tile Tiles now read macOS, Windows, Linux. OS_ORDER was doing two jobs: laying out the tiles and picking the OS the panel falls back to when detection fails. Since the server has no user agent to sniff, that fallback is what the prerendered HTML shows and what crawlers index, so reordering the constant in place would have quietly moved the indexed default from Windows to macOS. Splits it into OS_DISPLAY_ORDER and OS_FALLBACK_ORDER. Only the former changes here; the prerendered default is still Windows. --- components/DownloadButtonClient.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/components/DownloadButtonClient.tsx b/components/DownloadButtonClient.tsx index 7e058ac..fd17246 100644 --- a/components/DownloadButtonClient.tsx +++ b/components/DownloadButtonClient.tsx @@ -19,8 +19,15 @@ type DownloadButtonClientProps = { lastUpdated: string; }; -/** Display order, and the tie-break order for the server-rendered default. */ -const OS_ORDER = ["windows", "macos", "linux"] as const; +/** Left-to-right order of the OS tiles. */ +const OS_DISPLAY_ORDER = ["macos", "windows", "linux"] as const; + +/** + * Which OS the panel falls back to when detection fails or the visitor's OS + * has no build. Deliberately independent of the tile order above, so moving a + * tile does not change what the prerendered HTML shows. + */ +const OS_FALLBACK_ORDER = ["windows", "macos", "linux"] as const; // Dates are formatted in a fixed locale and timezone so the server render and // the hydrated client render agree. `toLocaleDateString()` would resolve @@ -75,7 +82,7 @@ export default function DownloadButtonClient({ pickedOS ?? (detectedOS && releases[detectedOS] ? detectedOS - : (OS_ORDER.find((os) => releases[os]) ?? null)); + : (OS_FALLBACK_ORDER.find((os) => releases[os]) ?? null)); const getReleaseForOS = (): LatestRelease | undefined => { if (!selectedOS) return undefined; @@ -150,7 +157,7 @@ export default function DownloadButtonClient({ {/* OS Selection */}
- {OS_ORDER.map((os) => { + {OS_DISPLAY_ORDER.map((os) => { const isSelected = selectedOS === os; const isDetected = detectedOS === os; const isAvailable = availableOSes.includes(os);