Skip to content

Commit 7d967b0

Browse files
committed
refactor: improve download route quality and error reporting
- Fix stale cache TTL to always outlive fresh cache (freshTtl + 3600) - Use Partial<Record<Arch, string>> for accurate asset typing - Extract fetchGitHub helper to deduplicate fetch+headers pattern - Centralize stale key construction in staleCacheKey helper - Parallelize KV writes with Promise.all - Add console.error logging for GitHub API failures - Add Windows Edge User-Agent test case
1 parent 2ed21e2 commit 7d967b0

2 files changed

Lines changed: 54 additions & 31 deletions

File tree

routes/download.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ describe("detectArch", () => {
4848
).toBe("x64");
4949
});
5050

51+
it("defaults to x64 for Windows Edge User-Agent", () => {
52+
expect(
53+
detectArch(
54+
undefined,
55+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0",
56+
),
57+
).toBe("x64");
58+
});
59+
5160
it("query param takes precedence over User-Agent", () => {
5261
expect(detectArch("x64", "Mozilla/5.0 (Windows NT 10.0; ARM64) AppleWebKit/537.36")).toBe(
5362
"x64",

routes/download.ts

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,12 @@ const ASSET_NAMES: Record<Arch, string> = {
99
};
1010
const LATEST_CACHE_TTL = 300; // 5 minutes
1111
const TAGGED_CACHE_TTL = 86400; // 24 hours
12-
const STALE_CACHE_TTL = 3600; // 1 hour fallback
1312

1413
type Arch = "x64" | "arm64";
1514

1615
interface CachedRelease {
1716
tag: string;
18-
assets: Record<Arch, string>;
17+
assets: Partial<Record<Arch, string>>;
1918
}
2019

2120
interface GitHubRelease {
@@ -38,7 +37,7 @@ export function detectArch(
3837
}
3938

4039
export function parseRelease(release: GitHubRelease): CachedRelease | null {
41-
const assets = {} as Record<Arch, string>;
40+
const assets: Partial<Record<Arch, string>> = {};
4241
for (const asset of release.assets) {
4342
if (asset.name === ASSET_NAMES.x64) assets.x64 = asset.browser_download_url;
4443
if (asset.name === ASSET_NAMES.arm64) assets.arm64 = asset.browser_download_url;
@@ -47,65 +46,80 @@ export function parseRelease(release: GitHubRelease): CachedRelease | null {
4746
return { tag: release.tag_name, assets };
4847
}
4948

50-
async function fetchRelease(
51-
tag: string | undefined,
52-
githubToken: string | undefined,
53-
): Promise<GitHubRelease | null> {
49+
async function fetchGitHub(path: string, githubToken: string | undefined): Promise<Response> {
5450
const headers: Record<string, string> = {
5551
Accept: "application/vnd.github.v3+json",
5652
"User-Agent": "vp-setup-exe-downloader",
5753
};
5854
if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
55+
return fetch(`https://api.github.com${path}`, { headers });
56+
}
5957

58+
async function fetchRelease(
59+
tag: string | undefined,
60+
githubToken: string | undefined,
61+
): Promise<GitHubRelease | null> {
6062
if (tag) {
61-
const url = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tags/${tag}`;
62-
const res = await fetch(url, { headers });
63-
if (!res.ok) return null;
63+
const res = await fetchGitHub(
64+
`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tags/${tag}`,
65+
githubToken,
66+
);
67+
if (!res.ok) {
68+
console.error(`GitHub API error: ${res.status} ${res.statusText} for tag ${tag}`);
69+
return null;
70+
}
6471
return (await res.json()) as GitHubRelease;
6572
}
6673

67-
// List releases to find the latest one with exe assets (includes pre-releases)
68-
const url = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases?per_page=10`;
69-
const res = await fetch(url, { headers });
70-
if (!res.ok) return null;
71-
const releases = (await res.json()) as GitHubRelease[];
72-
for (const release of releases) {
73-
if (release.assets.some((a) => a.name === ASSET_NAMES.x64 || a.name === ASSET_NAMES.arm64)) {
74-
return release;
75-
}
74+
// Includes pre-releases, unlike /releases/latest
75+
const res = await fetchGitHub(
76+
`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases?per_page=10`,
77+
githubToken,
78+
);
79+
if (!res.ok) {
80+
console.error(`GitHub API error: ${res.status} ${res.statusText} for releases list`);
81+
return null;
7682
}
77-
return null;
83+
const releases = (await res.json()) as GitHubRelease[];
84+
return (
85+
releases.find((r) =>
86+
r.assets.some((a) => a.name === ASSET_NAMES.x64 || a.name === ASSET_NAMES.arm64),
87+
) ?? null
88+
);
7889
}
7990

80-
function cacheKey(tag?: string): string {
91+
function cacheKey(tag: string | undefined): string {
8192
return tag ? `release:tag:${tag}` : "release:latest";
8293
}
8394

95+
function staleCacheKey(tag: string | undefined): string {
96+
return `${cacheKey(tag)}:stale`;
97+
}
98+
8499
async function getRelease(
85100
tag: string | undefined,
86101
githubToken: string | undefined,
87102
): Promise<CachedRelease | null> {
88103
const key = cacheKey(tag);
89-
90-
// Try fresh cache
91104
const cached = await kv.get<CachedRelease>(key);
92105
if (cached) return cached;
93106

94-
// Fetch from GitHub
95107
try {
96108
const release = await fetchRelease(tag, githubToken);
97109
if (!release) return null;
98110
const parsed = parseRelease(release);
99111
if (parsed) {
100112
const ttl = tag ? TAGGED_CACHE_TTL : LATEST_CACHE_TTL;
101-
await kv.put(key, parsed, { ttl });
102-
// Store stale fallback with longer TTL
103-
await kv.put(`${key}:stale`, parsed, { ttl: STALE_CACHE_TTL });
113+
const staleTtl = ttl + 3600;
114+
await Promise.all([
115+
kv.put(key, parsed, { ttl }),
116+
kv.put(staleCacheKey(tag), parsed, { ttl: staleTtl }),
117+
]);
104118
}
105119
return parsed;
106-
} catch {
107-
// On failure, try stale cache
108-
return await kv.get<CachedRelease>(`${key}:stale`);
120+
} catch (err) {
121+
console.error("Failed to fetch release from GitHub:", err);
122+
return await kv.get<CachedRelease>(staleCacheKey(tag));
109123
}
110124
}
111125

@@ -119,7 +133,7 @@ export const GET = defineHandler(async (c) => {
119133
return c.json({ error: "Invalid architecture. Use 'x64' or 'arm64'" }, 400);
120134
}
121135

122-
const githubToken = (c.env as Record<string, string>).GITHUB_TOKEN;
136+
const githubToken = c.env.GITHUB_TOKEN as string | undefined;
123137
const release = await getRelease(tag || undefined, githubToken);
124138

125139
if (!release) {

0 commit comments

Comments
 (0)