perf(website): cache GitHub release metadata in KV
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
import { env, waitUntil } from "cloudflare:workers";
|
||||||
|
|
||||||
|
export const GITHUB_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
interface CachedValue<T> {
|
||||||
|
fetchedAt: number;
|
||||||
|
value: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Validator<T> = (value: unknown) => value is T;
|
||||||
|
|
||||||
|
function getWebsiteCache(): KVNamespace | null {
|
||||||
|
return (env as { WEBSITE_CACHE?: KVNamespace }).WEBSITE_CACHE ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCachedValue<T>(value: unknown, isValue: Validator<T>): value is CachedValue<T> {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return typeof record.fetchedAt === "number" && isValue(record.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readCachedValue<T>(
|
||||||
|
key: string,
|
||||||
|
isValue: Validator<T>,
|
||||||
|
): Promise<CachedValue<T> | 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<T>(key: string, value: T): Promise<void> {
|
||||||
|
const cache = getWebsiteCache();
|
||||||
|
if (!cache) return;
|
||||||
|
await cache.put(
|
||||||
|
key,
|
||||||
|
JSON.stringify({
|
||||||
|
fetchedAt: Date.now(),
|
||||||
|
value,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBlockingColdCache<T>({
|
||||||
|
key,
|
||||||
|
isValue,
|
||||||
|
fetchFresh,
|
||||||
|
}: {
|
||||||
|
key: string;
|
||||||
|
isValue: Validator<T>;
|
||||||
|
fetchFresh: () => Promise<T>;
|
||||||
|
}): Promise<T> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import websitePackage from "../package.json";
|
import { getBlockingColdCache } from "./github-cache";
|
||||||
|
|
||||||
interface GitHubAsset {
|
interface GitHubAsset {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -50,46 +50,56 @@ interface ReleaseInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GITHUB_RELEASES_URL = "https://api.github.com/repos/getpaseo/paseo/releases?per_page=10";
|
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<ReleaseInfo> {
|
async function fetchLatestReadyRelease(): Promise<ReleaseInfo> {
|
||||||
const fallbackVersion = websitePackage.version.replace(/-.*$/, "");
|
const res = await fetch(GITHUB_RELEASES_URL, {
|
||||||
const fallback: ReleaseInfo = {
|
headers: {
|
||||||
version: fallbackVersion,
|
Accept: "application/vnd.github+json",
|
||||||
windowsX64Asset: `Paseo-Setup-${fallbackVersion}.exe`,
|
"User-Agent": "paseo-website",
|
||||||
windowsArm64Asset: null,
|
},
|
||||||
|
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 {
|
function isReleaseInfo(value: unknown): value is ReleaseInfo {
|
||||||
const res = await fetch(GITHUB_RELEASES_URL, {
|
if (typeof value !== "object" || value === null) return false;
|
||||||
headers: {
|
const record = value as Record<string, unknown>;
|
||||||
Accept: "application/vnd.github+json",
|
return (
|
||||||
"User-Agent": "paseo-website",
|
typeof record.version === "string" &&
|
||||||
},
|
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(record.version) &&
|
||||||
// Cloudflare Workers: cache the upstream response with stale-while-revalidate.
|
(typeof record.windowsX64Asset === "string" || record.windowsX64Asset === null) &&
|
||||||
// Fresh for 60s, serve stale for up to 300s while revalidating in background.
|
(typeof record.windowsArm64Asset === "string" || record.windowsArm64Asset === null) &&
|
||||||
// In non-Workers environments this is ignored.
|
(record.windowsX64Asset === null ||
|
||||||
cf: {
|
new RegExp(`^Paseo-Setup-${record.version.replaceAll(".", "\\.")}(?:-x64)?\\.exe$`).test(
|
||||||
cacheEverything: true,
|
record.windowsX64Asset,
|
||||||
cacheTtl: 60,
|
)) &&
|
||||||
cacheKey: "github-releases-latest",
|
(record.windowsArm64Asset === null ||
|
||||||
},
|
new RegExp(`^Paseo-Setup-${record.version.replaceAll(".", "\\.")}-arm64\\.exe$`).test(
|
||||||
} as RequestInit);
|
record.windowsArm64Asset,
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLatestRelease = createServerFn({ method: "GET" }).handler(async () => {
|
export const getLatestRelease = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
return fetchLatestReadyRelease();
|
return getBlockingColdCache({
|
||||||
|
key: RELEASE_CACHE_KEY,
|
||||||
|
isValue: isReleaseInfo,
|
||||||
|
fetchFresh: fetchLatestReadyRelease,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { getBlockingColdCache } from "./github-cache";
|
||||||
|
|
||||||
interface GitHubRepo {
|
interface GitHubRepo {
|
||||||
stargazers_count: number;
|
stargazers_count: number;
|
||||||
@@ -11,30 +12,35 @@ function formatStars(count: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const GITHUB_REPO_URL = "https://api.github.com/repos/getpaseo/paseo";
|
const GITHUB_REPO_URL = "https://api.github.com/repos/getpaseo/paseo";
|
||||||
|
const STARS_CACHE_KEY = "github-stars:v1";
|
||||||
|
|
||||||
async function fetchStarCount(): Promise<string> {
|
async function fetchStarCount(): Promise<string> {
|
||||||
try {
|
const res = await fetch(GITHUB_REPO_URL, {
|
||||||
const res = await fetch(GITHUB_REPO_URL, {
|
headers: {
|
||||||
headers: {
|
Accept: "application/vnd.github+json",
|
||||||
Accept: "application/vnd.github+json",
|
"User-Agent": "paseo-website",
|
||||||
"User-Agent": "paseo-website",
|
},
|
||||||
},
|
cf: {
|
||||||
cf: {
|
cacheEverything: true,
|
||||||
cacheEverything: true,
|
cacheTtl: 60,
|
||||||
cacheTtl: 60,
|
cacheKey: "github-repo-stars",
|
||||||
cacheKey: "github-repo-stars",
|
},
|
||||||
},
|
} as RequestInit);
|
||||||
} as RequestInit);
|
if (!res.ok) throw new Error(`github repo ${res.status}`);
|
||||||
if (!res.ok) return "";
|
|
||||||
|
|
||||||
const repo = (await res.json()) as GitHubRepo;
|
const repo = (await res.json()) as GitHubRepo;
|
||||||
return formatStars(repo.stargazers_count);
|
return formatStars(repo.stargazers_count);
|
||||||
} catch {
|
}
|
||||||
return "";
|
|
||||||
}
|
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 () => {
|
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 };
|
return { stars };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,3 +11,7 @@ routes = [
|
|||||||
|
|
||||||
[observability]
|
[observability]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
|
[[kv_namespaces]]
|
||||||
|
binding = "WEBSITE_CACHE"
|
||||||
|
id = "cb4d8779806544e9b673142a80c1eb5f"
|
||||||
|
|||||||
Reference in New Issue
Block a user