From 734e15e5a38bb4147147c0c61adc53280be9e4a2 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 7 May 2026 23:22:48 +0700 Subject: [PATCH] perf(website): cache GitHub release metadata in KV --- packages/website/src/github-cache.ts | 68 +++++++++++++++++++++++ packages/website/src/release.ts | 82 ++++++++++++++++------------ packages/website/src/stars.ts | 44 ++++++++------- packages/website/wrangler.toml | 4 ++ 4 files changed, 143 insertions(+), 55 deletions(-) create mode 100644 packages/website/src/github-cache.ts diff --git a/packages/website/src/github-cache.ts b/packages/website/src/github-cache.ts new file mode 100644 index 000000000..9e9c9daf6 --- /dev/null +++ b/packages/website/src/github-cache.ts @@ -0,0 +1,68 @@ +import { env, waitUntil } from "cloudflare:workers"; + +export const GITHUB_CACHE_TTL_MS = 5 * 60 * 1000; + +interface CachedValue { + fetchedAt: number; + value: T; +} + +type Validator = (value: unknown) => value is T; + +function getWebsiteCache(): KVNamespace | null { + return (env as { WEBSITE_CACHE?: KVNamespace }).WEBSITE_CACHE ?? null; +} + +function isCachedValue(value: unknown, isValue: Validator): value is CachedValue { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return typeof record.fetchedAt === "number" && isValue(record.value); +} + +async function readCachedValue( + key: string, + isValue: Validator, +): Promise | null> { + const cache = getWebsiteCache(); + if (!cache) return null; + const cached = await cache.get(key, { cacheTtl: 60, type: "json" }); + return isCachedValue(cached, isValue) ? cached : null; +} + +async function writeCachedValue(key: string, value: T): Promise { + const cache = getWebsiteCache(); + if (!cache) return; + await cache.put( + key, + JSON.stringify({ + fetchedAt: Date.now(), + value, + }), + ); +} + +export async function getBlockingColdCache({ + key, + isValue, + fetchFresh, +}: { + key: string; + isValue: Validator; + fetchFresh: () => Promise; +}): Promise { + const cached = await readCachedValue(key, isValue); + if (cached) { + if (Date.now() - cached.fetchedAt > GITHUB_CACHE_TTL_MS) { + waitUntil( + fetchFresh() + .then((fresh) => writeCachedValue(key, fresh)) + .catch(() => undefined), + ); + } + return cached.value; + } + + const fresh = await fetchFresh(); + await writeCachedValue(key, fresh); + return fresh; +} diff --git a/packages/website/src/release.ts b/packages/website/src/release.ts index 1ecb8c801..1b8566453 100644 --- a/packages/website/src/release.ts +++ b/packages/website/src/release.ts @@ -1,5 +1,5 @@ import { createServerFn } from "@tanstack/react-start"; -import websitePackage from "../package.json"; +import { getBlockingColdCache } from "./github-cache"; interface GitHubAsset { name: string; @@ -50,46 +50,56 @@ interface ReleaseInfo { } const GITHUB_RELEASES_URL = "https://api.github.com/repos/getpaseo/paseo/releases?per_page=10"; +const RELEASE_CACHE_KEY = "github-release:v1"; async function fetchLatestReadyRelease(): Promise { - const fallbackVersion = websitePackage.version.replace(/-.*$/, ""); - const fallback: ReleaseInfo = { - version: fallbackVersion, - windowsX64Asset: `Paseo-Setup-${fallbackVersion}.exe`, - windowsArm64Asset: null, + const res = await fetch(GITHUB_RELEASES_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "paseo-website", + }, + cf: { + cacheEverything: true, + cacheTtl: 60, + cacheKey: "github-releases-latest", + }, + } as RequestInit); + if (!res.ok) throw new Error(`github releases ${res.status}`); + + const releases = (await res.json()) as GitHubRelease[]; + const ready = releases.find((r) => !r.prerelease && !r.draft && hasRequiredAssets(r)); + if (!ready) throw new Error("no ready GitHub release found"); + const win = pickWindowsAssets(ready.assets); + return { + version: versionFromTag(ready.tag_name), + windowsX64Asset: win.x64, + windowsArm64Asset: win.arm64, }; +} - try { - const res = await fetch(GITHUB_RELEASES_URL, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "paseo-website", - }, - // Cloudflare Workers: cache the upstream response with stale-while-revalidate. - // Fresh for 60s, serve stale for up to 300s while revalidating in background. - // In non-Workers environments this is ignored. - cf: { - cacheEverything: true, - cacheTtl: 60, - cacheKey: "github-releases-latest", - }, - } as RequestInit); - if (!res.ok) return fallback; - - const releases = (await res.json()) as GitHubRelease[]; - const ready = releases.find((r) => !r.prerelease && !r.draft && hasRequiredAssets(r)); - if (!ready) return fallback; - const win = pickWindowsAssets(ready.assets); - return { - version: versionFromTag(ready.tag_name), - windowsX64Asset: win.x64, - windowsArm64Asset: win.arm64, - }; - } catch { - return fallback; - } +function isReleaseInfo(value: unknown): value is ReleaseInfo { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return ( + typeof record.version === "string" && + /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(record.version) && + (typeof record.windowsX64Asset === "string" || record.windowsX64Asset === null) && + (typeof record.windowsArm64Asset === "string" || record.windowsArm64Asset === null) && + (record.windowsX64Asset === null || + new RegExp(`^Paseo-Setup-${record.version.replaceAll(".", "\\.")}(?:-x64)?\\.exe$`).test( + record.windowsX64Asset, + )) && + (record.windowsArm64Asset === null || + new RegExp(`^Paseo-Setup-${record.version.replaceAll(".", "\\.")}-arm64\\.exe$`).test( + record.windowsArm64Asset, + )) + ); } export const getLatestRelease = createServerFn({ method: "GET" }).handler(async () => { - return fetchLatestReadyRelease(); + return getBlockingColdCache({ + key: RELEASE_CACHE_KEY, + isValue: isReleaseInfo, + fetchFresh: fetchLatestReadyRelease, + }); }); diff --git a/packages/website/src/stars.ts b/packages/website/src/stars.ts index cf5775e07..003c363bd 100644 --- a/packages/website/src/stars.ts +++ b/packages/website/src/stars.ts @@ -1,4 +1,5 @@ import { createServerFn } from "@tanstack/react-start"; +import { getBlockingColdCache } from "./github-cache"; interface GitHubRepo { stargazers_count: number; @@ -11,30 +12,35 @@ function formatStars(count: number): string { } const GITHUB_REPO_URL = "https://api.github.com/repos/getpaseo/paseo"; +const STARS_CACHE_KEY = "github-stars:v1"; async function fetchStarCount(): Promise { - try { - const res = await fetch(GITHUB_REPO_URL, { - headers: { - Accept: "application/vnd.github+json", - "User-Agent": "paseo-website", - }, - cf: { - cacheEverything: true, - cacheTtl: 60, - cacheKey: "github-repo-stars", - }, - } as RequestInit); - if (!res.ok) return ""; + const res = await fetch(GITHUB_REPO_URL, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "paseo-website", + }, + cf: { + cacheEverything: true, + cacheTtl: 60, + cacheKey: "github-repo-stars", + }, + } as RequestInit); + if (!res.ok) throw new Error(`github repo ${res.status}`); - const repo = (await res.json()) as GitHubRepo; - return formatStars(repo.stargazers_count); - } catch { - return ""; - } + const repo = (await res.json()) as GitHubRepo; + return formatStars(repo.stargazers_count); +} + +function isStars(value: unknown): value is string { + return typeof value === "string" && /^(\d+|\d+\.\d+k|\d+k)$/.test(value); } export const getStarCount = createServerFn({ method: "GET" }).handler(async () => { - const stars = await fetchStarCount(); + const stars = await getBlockingColdCache({ + key: STARS_CACHE_KEY, + isValue: isStars, + fetchFresh: fetchStarCount, + }); return { stars }; }); diff --git a/packages/website/wrangler.toml b/packages/website/wrangler.toml index 8732226d5..64aa1dfd7 100644 --- a/packages/website/wrangler.toml +++ b/packages/website/wrangler.toml @@ -11,3 +11,7 @@ routes = [ [observability] enabled = true + +[[kv_namespaces]] +binding = "WEBSITE_CACHE" +id = "cb4d8779806544e9b673142a80c1eb5f"