Skip to content

Commit b6e3244

Browse files
committed
fix: return 404 for non-existent tags instead of rendering broken page
Previously, requesting ?tag=not-exists would render the download page with broken download links because buildReleaseFromTag blindly constructed URLs. Now fetchRelease distinguishes GitHub 404 (tag doesn't exist) from other errors (rate limit, server error), and getRelease returns null on confirmed 404 — skipping the fallback chain. Also adds negative caching for 404 tags, Cache-Control: no-store on HTML responses, and JSON.stringify for Cloudflare Workers console.log.
1 parent 16968f5 commit b6e3244

2 files changed

Lines changed: 72 additions & 7 deletions

File tree

routes/index.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,20 @@ async function fetchGitHub(path: string, githubToken: string | undefined): Promi
5555
return fetch(`https://api.github.com${path}`, { headers });
5656
}
5757

58-
async function fetchRelease(
58+
// "not-found" means the tag definitively doesn't exist (GitHub 404).
59+
// null means the API failed (rate limit, network error) — fallbacks may still work.
60+
export async function fetchRelease(
5961
tag: string | undefined,
6062
githubToken: string | undefined,
61-
): Promise<GitHubRelease | null> {
63+
): Promise<GitHubRelease | null | "not-found"> {
6264
if (tag) {
6365
const res = await fetchGitHub(
6466
`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tags/${tag}`,
6567
githubToken,
6668
);
6769
if (!res.ok) {
6870
console.error(`GitHub API error: ${res.status} ${res.statusText} for tag ${tag}`);
69-
return null;
71+
return res.status === 404 ? "not-found" : null;
7072
}
7173
return (await res.json()) as GitHubRelease;
7274
}
@@ -83,7 +85,7 @@ async function fetchRelease(
8385
const releases = (await res.json()) as GitHubRelease[];
8486
console.log(
8587
"GitHub releases:",
86-
releases.map((r) => ({ tag: r.tag_name, assets: r.assets.map((a) => a.name) })),
88+
JSON.stringify(releases.map((r) => ({ tag: r.tag_name, assets: r.assets.map((a) => a.name) }))),
8789
);
8890
return (
8991
releases.find((r) =>
@@ -144,6 +146,11 @@ async function getRelease(
144146

145147
try {
146148
const release = await fetchRelease(tag, githubToken);
149+
if (release === "not-found") {
150+
// Cache the negative result to avoid repeated API calls for the same bad tag
151+
await kv.put(key, null, { ttl: LATEST_CACHE_TTL });
152+
return null;
153+
}
147154
if (release) {
148155
const parsed = parseRelease(release);
149156
if (parsed) {
@@ -328,5 +335,5 @@ export const GET = defineHandler(async (c) => {
328335
if (!release) {
329336
return c.json({ error: tag ? `Release '${tag}' not found` : "No release found" }, 404);
330337
}
331-
return c.html(renderDownloadPage(release));
338+
return c.html(renderDownloadPage(release), { headers: { "Cache-Control": "no-store" } });
332339
});

tests/index.test.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { describe, expect, it } from "vite-plus/test";
2-
import { buildReleaseFromTag, detectArch, parseRelease } from "../routes/index";
1+
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2+
import { buildReleaseFromTag, detectArch, fetchRelease, parseRelease } from "../routes/index";
33

44
describe("detectArch", () => {
55
it("defaults to x64 when no query param or user-agent", () => {
@@ -133,3 +133,61 @@ describe("buildReleaseFromTag", () => {
133133
});
134134
});
135135
});
136+
137+
describe("fetchRelease", () => {
138+
afterEach(() => vi.unstubAllGlobals());
139+
140+
it('returns "not-found" when GitHub returns 404 for a tag', async () => {
141+
vi.stubGlobal(
142+
"fetch",
143+
vi
144+
.fn()
145+
.mockResolvedValue(new Response("Not Found", { status: 404, statusText: "Not Found" })),
146+
);
147+
const result = await fetchRelease("not-exists", undefined);
148+
expect(result).toBe("not-found");
149+
});
150+
151+
it("returns null when GitHub returns 403 (rate limited) for a tag", async () => {
152+
vi.stubGlobal(
153+
"fetch",
154+
vi
155+
.fn()
156+
.mockResolvedValue(new Response("Forbidden", { status: 403, statusText: "Forbidden" })),
157+
);
158+
const result = await fetchRelease("v1.0.0", undefined);
159+
expect(result).toBeNull();
160+
});
161+
162+
it("returns null when GitHub returns 500 for a tag", async () => {
163+
vi.stubGlobal(
164+
"fetch",
165+
vi.fn().mockResolvedValue(
166+
new Response("Internal Server Error", {
167+
status: 500,
168+
statusText: "Internal Server Error",
169+
}),
170+
),
171+
);
172+
const result = await fetchRelease("v1.0.0", undefined);
173+
expect(result).toBeNull();
174+
});
175+
176+
it("returns the release when GitHub returns 200 for a tag", async () => {
177+
const release = {
178+
tag_name: "v1.0.0",
179+
assets: [
180+
{
181+
name: "vp-setup-x86_64-pc-windows-msvc.exe",
182+
browser_download_url: "https://example.com/x64.exe",
183+
},
184+
],
185+
};
186+
vi.stubGlobal(
187+
"fetch",
188+
vi.fn().mockResolvedValue(new Response(JSON.stringify(release), { status: 200 })),
189+
);
190+
const result = await fetchRelease("v1.0.0", undefined);
191+
expect(result).toEqual(release);
192+
});
193+
});

0 commit comments

Comments
 (0)