Publish the latest Android version code (#2085)
* feat(website): publish latest Android version code * fix(website): select latest Android APK release
This commit is contained in:
@@ -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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { env, waitUntil } from "cloudflare:workers";
|
|
||||||
|
|
||||||
export const GITHUB_CACHE_TTL_MS = 5 * 60 * 1000;
|
export const GITHUB_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface WebsiteCacheContext {
|
||||||
|
cache: KVNamespace | null;
|
||||||
|
waitUntil: (promise: Promise<unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface CachedValue<T> {
|
interface CachedValue<T> {
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
value: T;
|
value: T;
|
||||||
@@ -9,10 +12,6 @@ interface CachedValue<T> {
|
|||||||
|
|
||||||
type Validator<T> = (value: unknown) => value is 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> {
|
function isCachedValue<T>(value: unknown, isValue: Validator<T>): value is CachedValue<T> {
|
||||||
if (typeof value !== "object" || value === null) return false;
|
if (typeof value !== "object" || value === null) return false;
|
||||||
const record = value as Record<string, unknown>;
|
const record = value as Record<string, unknown>;
|
||||||
@@ -20,17 +19,20 @@ function isCachedValue<T>(value: unknown, isValue: Validator<T>): value is Cache
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readCachedValue<T>(
|
async function readCachedValue<T>(
|
||||||
|
cache: KVNamespace | null,
|
||||||
key: string,
|
key: string,
|
||||||
isValue: Validator<T>,
|
isValue: Validator<T>,
|
||||||
): Promise<CachedValue<T> | null> {
|
): Promise<CachedValue<T> | null> {
|
||||||
const cache = getWebsiteCache();
|
|
||||||
if (!cache) return null;
|
if (!cache) return null;
|
||||||
const cached = await cache.get(key, { cacheTtl: 60, type: "json" });
|
const cached = await cache.get(key, { cacheTtl: 60, type: "json" });
|
||||||
return isCachedValue(cached, isValue) ? cached : null;
|
return isCachedValue(cached, isValue) ? cached : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeCachedValue<T>(key: string, value: T): Promise<void> {
|
async function writeCachedValue<T>(
|
||||||
const cache = getWebsiteCache();
|
cache: KVNamespace | null,
|
||||||
|
key: string,
|
||||||
|
value: T,
|
||||||
|
): Promise<void> {
|
||||||
if (!cache) return;
|
if (!cache) return;
|
||||||
await cache.put(
|
await cache.put(
|
||||||
key,
|
key,
|
||||||
@@ -42,20 +44,22 @@ async function writeCachedValue<T>(key: string, value: T): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getBlockingColdCache<T>({
|
export async function getBlockingColdCache<T>({
|
||||||
|
context,
|
||||||
key,
|
key,
|
||||||
isValue,
|
isValue,
|
||||||
fetchFresh,
|
fetchFresh,
|
||||||
}: {
|
}: {
|
||||||
|
context: WebsiteCacheContext;
|
||||||
key: string;
|
key: string;
|
||||||
isValue: Validator<T>;
|
isValue: Validator<T>;
|
||||||
fetchFresh: () => Promise<T>;
|
fetchFresh: () => Promise<T>;
|
||||||
}): Promise<T> {
|
}): Promise<T> {
|
||||||
const cached = await readCachedValue(key, isValue);
|
const cached = await readCachedValue(context.cache, key, isValue);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
if (Date.now() - cached.fetchedAt > GITHUB_CACHE_TTL_MS) {
|
if (Date.now() - cached.fetchedAt > GITHUB_CACHE_TTL_MS) {
|
||||||
waitUntil(
|
context.waitUntil(
|
||||||
fetchFresh()
|
fetchFresh()
|
||||||
.then((fresh) => writeCachedValue(key, fresh))
|
.then((fresh) => writeCachedValue(context.cache, key, fresh))
|
||||||
.catch(() => undefined),
|
.catch(() => undefined),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -63,6 +67,6 @@ export async function getBlockingColdCache<T>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fresh = await fetchFresh();
|
const fresh = await fetchFresh();
|
||||||
await writeCachedValue(key, fresh);
|
await writeCachedValue(context.cache, key, fresh);
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<GitHubRelease[]> {
|
||||||
|
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<ReleaseInfo> {
|
||||||
|
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<string> {
|
||||||
|
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<string, unknown>;
|
||||||
|
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<ReleaseInfo> {
|
||||||
|
return getBlockingColdCache({
|
||||||
|
context,
|
||||||
|
key: RELEASE_CACHE_KEY,
|
||||||
|
isValue: isReleaseInfo,
|
||||||
|
fetchFresh: fetchLatestReadyRelease,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLatestAndroidVersion(context: WebsiteCacheContext): Promise<string> {
|
||||||
|
return getBlockingColdCache({
|
||||||
|
context,
|
||||||
|
key: ANDROID_RELEASE_CACHE_KEY,
|
||||||
|
isValue: isAndroidVersion,
|
||||||
|
fetchFresh: fetchLatestAndroidVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,121 +1,7 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
import { getBlockingColdCache } from "./github-cache";
|
import { getWebsiteCacheContext } from "./cloudflare-cache";
|
||||||
|
import { getLatestReleaseInfo } from "./latest-release";
|
||||||
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<ReleaseInfo> {
|
|
||||||
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<string, unknown>;
|
|
||||||
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 const getLatestRelease = createServerFn({ method: "GET" }).handler(async () => {
|
export const getLatestRelease = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
return getBlockingColdCache({
|
return getLatestReleaseInfo(getWebsiteCacheContext());
|
||||||
key: RELEASE_CACHE_KEY,
|
|
||||||
isValue: isReleaseInfo,
|
|
||||||
fetchFresh: fetchLatestReadyRelease,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import startEntry from "@tanstack/react-start/server-entry";
|
import startEntry from "@tanstack/react-start/server-entry";
|
||||||
|
import { getAndroidVersionCode } from "~/android-version";
|
||||||
import { getDoc } from "~/docs";
|
import { getDoc } from "~/docs";
|
||||||
|
import { getLatestAndroidVersion } from "~/latest-release";
|
||||||
import { buildLlmsTxt } from "~/llms";
|
import { buildLlmsTxt } from "~/llms";
|
||||||
|
|
||||||
const CANONICAL_HOST = "paseo.sh";
|
const CANONICAL_HOST = "paseo.sh";
|
||||||
|
|
||||||
type FetchArgs = Parameters<typeof startEntry.fetch>;
|
interface WebsiteEnv {
|
||||||
|
WEBSITE_CACHE?: KVNamespace;
|
||||||
|
}
|
||||||
|
|
||||||
function markdownResponse(body: string): Response {
|
function markdownResponse(body: string): Response {
|
||||||
return new Response(body, {
|
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 {
|
function docSlugFromMarkdownPath(pathname: string): string | null {
|
||||||
if (pathname === "/docs.md") return "";
|
if (pathname === "/docs.md") return "";
|
||||||
const match = pathname.match(/^\/docs\/(.+)\.md$/);
|
const match = pathname.match(/^\/docs\/(.+)\.md$/);
|
||||||
@@ -22,8 +35,7 @@ function docSlugFromMarkdownPath(pathname: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
async fetch(...args: FetchArgs): Promise<Response> {
|
async fetch(request: Request, env: WebsiteEnv, context: ExecutionContext): Promise<Response> {
|
||||||
const [request] = args;
|
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
|
|
||||||
const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
||||||
@@ -43,6 +55,14 @@ export default {
|
|||||||
return markdownResponse(buildLlmsTxt());
|
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);
|
const slug = docSlugFromMarkdownPath(url.pathname);
|
||||||
if (slug !== null) {
|
if (slug !== null) {
|
||||||
const doc = getDoc(slug);
|
const doc = getDoc(slug);
|
||||||
@@ -50,6 +70,6 @@ export default {
|
|||||||
return markdownResponse(doc.content);
|
return markdownResponse(doc.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
return startEntry.fetch(...args);
|
return startEntry.fetch(request);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { getWebsiteCacheContext } from "./cloudflare-cache";
|
||||||
import { getBlockingColdCache } from "./github-cache";
|
import { getBlockingColdCache } from "./github-cache";
|
||||||
|
|
||||||
interface GitHubRepo {
|
interface GitHubRepo {
|
||||||
@@ -38,6 +39,7 @@ function isStars(value: unknown): value is string {
|
|||||||
|
|
||||||
export const getStarCount = createServerFn({ method: "GET" }).handler(async () => {
|
export const getStarCount = createServerFn({ method: "GET" }).handler(async () => {
|
||||||
const stars = await getBlockingColdCache({
|
const stars = await getBlockingColdCache({
|
||||||
|
context: getWebsiteCacheContext(),
|
||||||
key: STARS_CACHE_KEY,
|
key: STARS_CACHE_KEY,
|
||||||
isValue: isStars,
|
isValue: isStars,
|
||||||
fetchFresh: fetchStarCount,
|
fetchFresh: fetchStarCount,
|
||||||
|
|||||||
Reference in New Issue
Block a user