diff --git a/packages/website/src/android-version.test.ts b/packages/website/src/android-version.test.ts new file mode 100644 index 000000000..fa8efba09 --- /dev/null +++ b/packages/website/src/android-version.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { getAndroidVersionCode } from "./android-version"; + +describe("getAndroidVersionCode", () => { + it("matches the Android build version code for a stable release", () => { + expect(getAndroidVersionCode("0.1.107")).toBe(1107); + }); + + it("rejects versions that cannot map to a unique Android version code", () => { + expect(() => getAndroidVersionCode("0.1000.0")).toThrow( + "Cannot derive collision-free Android versionCode from version: 0.1000.0", + ); + }); +}); diff --git a/packages/website/src/android-version.ts b/packages/website/src/android-version.ts new file mode 100644 index 000000000..250c0df32 --- /dev/null +++ b/packages/website/src/android-version.ts @@ -0,0 +1,22 @@ +export function getAndroidVersionCode(version: string): number { + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version); + if (!match) { + throw new Error(`Cannot derive Android versionCode from non-semver version: ${version}`); + } + + const [, majorText, minorText, patchText] = match; + const major = Number(majorText); + const minor = Number(minorText); + const patch = Number(patchText); + + if (minor > 999 || patch > 999) { + throw new Error(`Cannot derive collision-free Android versionCode from version: ${version}`); + } + + const versionCode = major * 1_000_000 + minor * 1_000 + patch; + if (!Number.isSafeInteger(versionCode) || versionCode <= 0 || versionCode > 2_100_000_000) { + throw new Error(`Derived Android versionCode is out of range: ${versionCode}`); + } + + return versionCode; +} diff --git a/packages/website/src/cloudflare-cache.ts b/packages/website/src/cloudflare-cache.ts new file mode 100644 index 000000000..2c61baaf7 --- /dev/null +++ b/packages/website/src/cloudflare-cache.ts @@ -0,0 +1,9 @@ +import { env, waitUntil } from "cloudflare:workers"; +import type { WebsiteCacheContext } from "./github-cache"; + +export function getWebsiteCacheContext(): WebsiteCacheContext { + return { + cache: (env as { WEBSITE_CACHE?: KVNamespace }).WEBSITE_CACHE ?? null, + waitUntil, + }; +} diff --git a/packages/website/src/github-cache.ts b/packages/website/src/github-cache.ts index 9e9c9daf6..8ee08457e 100644 --- a/packages/website/src/github-cache.ts +++ b/packages/website/src/github-cache.ts @@ -1,7 +1,10 @@ -import { env, waitUntil } from "cloudflare:workers"; - export const GITHUB_CACHE_TTL_MS = 5 * 60 * 1000; +export interface WebsiteCacheContext { + cache: KVNamespace | null; + waitUntil: (promise: Promise) => void; +} + interface CachedValue { fetchedAt: number; value: T; @@ -9,10 +12,6 @@ interface CachedValue { 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; @@ -20,17 +19,20 @@ function isCachedValue(value: unknown, isValue: Validator): value is Cache } async function readCachedValue( + cache: KVNamespace | null, 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(); +async function writeCachedValue( + cache: KVNamespace | null, + key: string, + value: T, +): Promise { if (!cache) return; await cache.put( key, @@ -42,20 +44,22 @@ async function writeCachedValue(key: string, value: T): Promise { } export async function getBlockingColdCache({ + context, key, isValue, fetchFresh, }: { + context: WebsiteCacheContext; key: string; isValue: Validator; fetchFresh: () => Promise; }): Promise { - const cached = await readCachedValue(key, isValue); + const cached = await readCachedValue(context.cache, key, isValue); if (cached) { if (Date.now() - cached.fetchedAt > GITHUB_CACHE_TTL_MS) { - waitUntil( + context.waitUntil( fetchFresh() - .then((fresh) => writeCachedValue(key, fresh)) + .then((fresh) => writeCachedValue(context.cache, key, fresh)) .catch(() => undefined), ); } @@ -63,6 +67,6 @@ export async function getBlockingColdCache({ } const fresh = await fetchFresh(); - await writeCachedValue(key, fresh); + await writeCachedValue(context.cache, key, fresh); return fresh; } diff --git a/packages/website/src/latest-release.test.ts b/packages/website/src/latest-release.test.ts new file mode 100644 index 000000000..e10c26446 --- /dev/null +++ b/packages/website/src/latest-release.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { getLatestAndroidVersionFromReleases, type GitHubRelease } from "./latest-release"; + +function release({ + version, + hasApk, + prerelease = false, +}: { + version: string; + hasApk: boolean; + prerelease?: boolean; +}): GitHubRelease { + const tag = `v${version}`; + return { + tag_name: tag, + assets: hasApk ? [{ name: `paseo-${tag}-android.apk` }] : [], + prerelease, + draft: false, + }; +} + +describe("getLatestAndroidVersionFromReleases", () => { + it("selects the latest stable release that contains an Android APK", () => { + const releases = [ + release({ version: "0.1.109", hasApk: true, prerelease: true }), + release({ version: "0.1.108", hasApk: false }), + release({ version: "0.1.107", hasApk: true }), + ]; + + expect(getLatestAndroidVersionFromReleases(releases)).toBe("0.1.107"); + }); +}); diff --git a/packages/website/src/latest-release.ts b/packages/website/src/latest-release.ts new file mode 100644 index 000000000..afdbf6b56 --- /dev/null +++ b/packages/website/src/latest-release.ts @@ -0,0 +1,160 @@ +import { getBlockingColdCache, type WebsiteCacheContext } from "./github-cache"; + +interface GitHubAsset { + name: string; +} + +export interface GitHubRelease { + tag_name: string; + assets: GitHubAsset[]; + prerelease: boolean; + draft: boolean; +} + +export interface ReleaseInfo { + version: string; + linuxAppImageAsset: string; + windowsX64Asset: string | null; + windowsArm64Asset: string | null; +} + +const LINUX_APPIMAGE_ASSET_PATTERN = + /^Paseo-(?:\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)-)?x86_64\.AppImage$/; + +const REQUIRED_ASSET_PATTERNS = [ + /Paseo-.*-arm64\.dmg$/, + LINUX_APPIMAGE_ASSET_PATTERN, + /Paseo-Setup-.*\.exe$/, +]; + +const GITHUB_RELEASES_URL = "https://api.github.com/repos/getpaseo/paseo/releases?per_page=10"; +const RELEASE_CACHE_KEY = "github-release:v1"; +const ANDROID_RELEASE_CACHE_KEY = "github-android-release:v1"; + +function hasRequiredAssets(release: GitHubRelease): boolean { + return REQUIRED_ASSET_PATTERNS.every((pattern) => + release.assets.some((asset) => pattern.test(asset.name)), + ); +} + +function pickWindowsAssets(assets: GitHubAsset[]) { + const x64Suffixed = assets.find((asset) => /Paseo-Setup-.*-x64\.exe$/.test(asset.name)); + const arm64 = assets.find((asset) => /Paseo-Setup-.*-arm64\.exe$/.test(asset.name)); + const legacy = assets.find( + (asset) => + /Paseo-Setup-.*\.exe$/.test(asset.name) && + !asset.name.endsWith("-x64.exe") && + !asset.name.endsWith("-arm64.exe"), + ); + return { + x64: (x64Suffixed ?? legacy)?.name ?? null, + arm64: arm64?.name ?? null, + }; +} + +function pickLinuxAppImageAsset(assets: GitHubAsset[]) { + return assets.find((asset) => LINUX_APPIMAGE_ASSET_PATTERN.test(asset.name))?.name ?? null; +} + +function versionFromTag(tag: string): string { + return tag.replace(/^v/, ""); +} + +async function fetchGitHubReleases(): Promise { + const response = 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 (!response.ok) throw new Error(`github releases ${response.status}`); + + return (await response.json()) as GitHubRelease[]; +} + +async function fetchLatestReadyRelease(): Promise { + const releases = await fetchGitHubReleases(); + const ready = releases.find( + (release) => !release.prerelease && !release.draft && hasRequiredAssets(release), + ); + if (!ready) throw new Error("no ready GitHub release found"); + + const windowsAssets = pickWindowsAssets(ready.assets); + const linuxAppImageAsset = pickLinuxAppImageAsset(ready.assets); + if (!linuxAppImageAsset) throw new Error("ready release missing Linux AppImage asset"); + + return { + version: versionFromTag(ready.tag_name), + linuxAppImageAsset, + windowsX64Asset: windowsAssets.x64, + windowsArm64Asset: windowsAssets.arm64, + }; +} + +export function getLatestAndroidVersionFromReleases(releases: GitHubRelease[]): string { + const release = releases.find((candidate) => { + if (candidate.prerelease || candidate.draft) return false; + const version = versionFromTag(candidate.tag_name); + if (!/^\d+\.\d+\.\d+$/.test(version)) return false; + return candidate.assets.some( + (asset) => asset.name === `paseo-${candidate.tag_name}-android.apk`, + ); + }); + if (!release) throw new Error("no stable GitHub release with an Android APK found"); + return versionFromTag(release.tag_name); +} + +async function fetchLatestAndroidVersion(): Promise { + return getLatestAndroidVersionFromReleases(await fetchGitHubReleases()); +} + +function isAndroidVersion(value: unknown): value is string { + return typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value); +} + +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.linuxAppImageAsset === "string" && + (record.linuxAppImageAsset === "Paseo-x86_64.AppImage" || + new RegExp(`^Paseo-${record.version.replaceAll(".", "\\.")}-x86_64\\.AppImage$`).test( + record.linuxAppImageAsset, + )) && + (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 async function getLatestReleaseInfo(context: WebsiteCacheContext): Promise { + return getBlockingColdCache({ + context, + key: RELEASE_CACHE_KEY, + isValue: isReleaseInfo, + fetchFresh: fetchLatestReadyRelease, + }); +} + +export async function getLatestAndroidVersion(context: WebsiteCacheContext): Promise { + return getBlockingColdCache({ + context, + key: ANDROID_RELEASE_CACHE_KEY, + isValue: isAndroidVersion, + fetchFresh: fetchLatestAndroidVersion, + }); +} diff --git a/packages/website/src/release.ts b/packages/website/src/release.ts index 815e54ed1..09a759cbf 100644 --- a/packages/website/src/release.ts +++ b/packages/website/src/release.ts @@ -1,121 +1,7 @@ import { createServerFn } from "@tanstack/react-start"; -import { getBlockingColdCache } from "./github-cache"; - -interface GitHubAsset { - name: string; -} - -interface GitHubRelease { - tag_name: string; - assets: GitHubAsset[]; - prerelease: boolean; - draft: boolean; -} - -const LINUX_APPIMAGE_ASSET_PATTERN = - /^Paseo-(?:\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)-)?x86_64\.AppImage$/; - -const REQUIRED_ASSET_PATTERNS = [ - /Paseo-.*-arm64\.dmg$/, // Mac Apple Silicon - LINUX_APPIMAGE_ASSET_PATTERN, // Linux AppImage - /Paseo-Setup-.*\.exe$/, // Windows (any arch) -]; - -function hasRequiredAssets(release: GitHubRelease): boolean { - return REQUIRED_ASSET_PATTERNS.every((pattern) => - release.assets.some((asset) => pattern.test(asset.name)), - ); -} - -function pickWindowsAssets(assets: GitHubAsset[]) { - const x64Suffixed = assets.find((a) => /Paseo-Setup-.*-x64\.exe$/.test(a.name)); - const arm64 = assets.find((a) => /Paseo-Setup-.*-arm64\.exe$/.test(a.name)); - const legacy = assets.find( - (a) => - /Paseo-Setup-.*\.exe$/.test(a.name) && - !a.name.endsWith("-x64.exe") && - !a.name.endsWith("-arm64.exe"), - ); - return { - x64: (x64Suffixed ?? legacy)?.name ?? null, - arm64: arm64?.name ?? null, - }; -} - -function pickLinuxAppImageAsset(assets: GitHubAsset[]) { - return assets.find((a) => LINUX_APPIMAGE_ASSET_PATTERN.test(a.name))?.name ?? null; -} - -function versionFromTag(tag: string): string { - return tag.replace(/^v/, ""); -} - -interface ReleaseInfo { - version: string; - linuxAppImageAsset: string; - windowsX64Asset: string | null; - windowsArm64Asset: string | null; -} - -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 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); - const linuxAppImageAsset = pickLinuxAppImageAsset(ready.assets); - if (!linuxAppImageAsset) throw new Error("ready release missing Linux AppImage asset"); - return { - version: versionFromTag(ready.tag_name), - linuxAppImageAsset, - windowsX64Asset: win.x64, - windowsArm64Asset: win.arm64, - }; -} - -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.linuxAppImageAsset === "string" && - (record.linuxAppImageAsset === "Paseo-x86_64.AppImage" || - new RegExp(`^Paseo-${record.version.replaceAll(".", "\\.")}-x86_64\\.AppImage$`).test( - record.linuxAppImageAsset, - )) && - (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, - )) - ); -} +import { getWebsiteCacheContext } from "./cloudflare-cache"; +import { getLatestReleaseInfo } from "./latest-release"; export const getLatestRelease = createServerFn({ method: "GET" }).handler(async () => { - return getBlockingColdCache({ - key: RELEASE_CACHE_KEY, - isValue: isReleaseInfo, - fetchFresh: fetchLatestReadyRelease, - }); + return getLatestReleaseInfo(getWebsiteCacheContext()); }); diff --git a/packages/website/src/server-entry.ts b/packages/website/src/server-entry.ts index 1f48e9854..18763f6df 100644 --- a/packages/website/src/server-entry.ts +++ b/packages/website/src/server-entry.ts @@ -1,10 +1,14 @@ import startEntry from "@tanstack/react-start/server-entry"; +import { getAndroidVersionCode } from "~/android-version"; import { getDoc } from "~/docs"; +import { getLatestAndroidVersion } from "~/latest-release"; import { buildLlmsTxt } from "~/llms"; const CANONICAL_HOST = "paseo.sh"; -type FetchArgs = Parameters; +interface WebsiteEnv { + WEBSITE_CACHE?: KVNamespace; +} function markdownResponse(body: string): Response { return new Response(body, { @@ -15,6 +19,15 @@ function markdownResponse(body: string): Response { }); } +function plainTextResponse(body: string): Response { + return new Response(body, { + headers: { + "content-type": "text/plain; charset=utf-8", + "cache-control": "public, max-age=300, s-maxage=300", + }, + }); +} + function docSlugFromMarkdownPath(pathname: string): string | null { if (pathname === "/docs.md") return ""; const match = pathname.match(/^\/docs\/(.+)\.md$/); @@ -22,8 +35,7 @@ function docSlugFromMarkdownPath(pathname: string): string | null { } export default { - async fetch(...args: FetchArgs): Promise { - const [request] = args; + async fetch(request: Request, env: WebsiteEnv, context: ExecutionContext): Promise { const url = new URL(request.url); const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1"; @@ -43,6 +55,14 @@ export default { return markdownResponse(buildLlmsTxt()); } + if (url.pathname === "/android-version.txt") { + const version = await getLatestAndroidVersion({ + cache: env.WEBSITE_CACHE ?? null, + waitUntil: (promise) => context.waitUntil(promise), + }); + return plainTextResponse(`${getAndroidVersionCode(version)}\n`); + } + const slug = docSlugFromMarkdownPath(url.pathname); if (slug !== null) { const doc = getDoc(slug); @@ -50,6 +70,6 @@ export default { return markdownResponse(doc.content); } - return startEntry.fetch(...args); + return startEntry.fetch(request); }, }; diff --git a/packages/website/src/stars.ts b/packages/website/src/stars.ts index 003c363bd..d90128640 100644 --- a/packages/website/src/stars.ts +++ b/packages/website/src/stars.ts @@ -1,4 +1,5 @@ import { createServerFn } from "@tanstack/react-start"; +import { getWebsiteCacheContext } from "./cloudflare-cache"; import { getBlockingColdCache } from "./github-cache"; interface GitHubRepo { @@ -38,6 +39,7 @@ function isStars(value: unknown): value is string { export const getStarCount = createServerFn({ method: "GET" }).handler(async () => { const stars = await getBlockingColdCache({ + context: getWebsiteCacheContext(), key: STARS_CACHE_KEY, isValue: isStars, fetchFresh: fetchStarCount,