Skip to content

Commit 5d67208

Browse files
committed
fix: replace GitHub API with npm registry for version resolution
The GitHub API was hitting 403 Forbidden errors due to rate limiting. Since download URLs are deterministic (constructable from tag alone), the GitHub API is unnecessary. Now specific tags resolve instantly with zero network calls, and the latest version is resolved via the npm registry dist-tags.
1 parent d5052c6 commit 5d67208

3 files changed

Lines changed: 40 additions & 244 deletions

File tree

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ Deployed to [Cloudflare Workers](https://workers.cloudflare.com/) via [Void](htt
4040

4141
### Environment variables
4242

43-
| Variable | Required | Description |
44-
| -------------- | -------- | ---------------------------------------------------------- |
45-
| `GITHUB_TOKEN` | No | GitHub token for higher API rate limits (60/hr -> 5000/hr) |
46-
| `VOID_TOKEN` | Yes (CI) | Void deployment token |
43+
| Variable | Required | Description |
44+
| ------------ | -------- | --------------------- |
45+
| `VOID_TOKEN` | Yes (CI) | Void deployment token |

routes/index.ts

Lines changed: 20 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ const ASSET_NAMES: Record<Arch, string> = {
1111
arm64: "vp-setup-aarch64-pc-windows-msvc.exe",
1212
};
1313
const LATEST_CACHE_TTL = 300; // 5 minutes
14-
const TAGGED_CACHE_TTL = 86400; // 24 hours
1514
const DEFAULT_DIST_TAG = "latest";
1615

1716
type Arch = "x64" | "arm64";
@@ -21,11 +20,6 @@ interface CachedRelease {
2120
assets: Partial<Record<Arch, string>>;
2221
}
2322

24-
interface GitHubRelease {
25-
tag_name: string;
26-
assets: Array<{ name: string; browser_download_url: string }>;
27-
}
28-
2923
export function detectArch(
3024
queryArch: string | undefined,
3125
userAgent: string | undefined,
@@ -40,56 +34,11 @@ export function detectArch(
4034
return "x64";
4135
}
4236

43-
export function parseRelease(release: GitHubRelease): CachedRelease | null {
44-
const assets: Partial<Record<Arch, string>> = {};
45-
for (const asset of release.assets) {
46-
if (asset.name === ASSET_NAMES.x64) assets.x64 = asset.browser_download_url;
47-
if (asset.name === ASSET_NAMES.arm64) assets.arm64 = asset.browser_download_url;
48-
}
49-
if (!assets.x64 && !assets.arm64) return null;
50-
return { tag: release.tag_name, assets };
51-
}
52-
53-
async function fetchGitHub(path: string, githubToken: string | undefined): Promise<Response> {
54-
const headers: Record<string, string> = {
55-
Accept: "application/vnd.github.v3+json",
56-
"User-Agent": "vp-setup-exe-downloader",
57-
};
58-
if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
59-
return fetch(`https://api.github.com${path}`, { headers });
60-
}
61-
62-
// "not-found" means the tag definitively doesn't exist (GitHub 404).
63-
// null means the API failed (rate limit, network error) — fallbacks may still work.
64-
export async function fetchRelease(
65-
tag: string | undefined,
66-
githubToken: string | undefined,
67-
): Promise<GitHubRelease | null | "not-found"> {
68-
if (tag) {
69-
const res = await fetchGitHub(
70-
`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tags/${tag}`,
71-
githubToken,
72-
);
73-
if (!res.ok) {
74-
console.error(`GitHub API error: ${res.status} ${res.statusText} for tag ${tag}`);
75-
return res.status === 404 ? "not-found" : null;
76-
}
77-
return (await res.json()) as GitHubRelease;
78-
}
79-
37+
export async function resolveRelease(tag: string | undefined): Promise<CachedRelease | null> {
38+
if (tag) return buildReleaseFromTag(tag);
8039
const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG);
8140
if (!version) return null;
82-
const res = await fetchGitHub(
83-
`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tags/v${version}`,
84-
githubToken,
85-
);
86-
if (!res.ok) {
87-
console.error(`GitHub API error: ${res.status} ${res.statusText} for default tag v${version}`);
88-
// Treat all failures (incl. 404) as transient so getRelease doesn't cache a negative
89-
// under `release:latest` when npm/GitHub are momentarily out of sync.
90-
return null;
91-
}
92-
return (await res.json()) as GitHubRelease;
41+
return buildReleaseFromTag(`v${version}`);
9342
}
9443

9544
export function buildReleaseFromTag(tag: string): CachedRelease {
@@ -128,47 +77,26 @@ function staleCacheKey(tag: string | undefined): string {
12877
return `${cacheKey(tag)}:stale`;
12978
}
13079

131-
async function getRelease(
132-
tag: string | undefined,
133-
githubToken: string | undefined,
134-
): Promise<CachedRelease | null> {
135-
const key = cacheKey(tag);
80+
async function getRelease(tag: string | undefined): Promise<CachedRelease | null> {
81+
// Specific tag: deterministic URL construction, no network or cache needed
82+
if (tag) return buildReleaseFromTag(tag);
83+
84+
// "Latest" path: use KV cache to avoid hitting npm on every request
85+
const key = cacheKey(undefined);
13686
const cached = await kv.get<CachedRelease>(key);
13787
if (cached) return cached;
13888

139-
try {
140-
const release = await fetchRelease(tag, githubToken);
141-
if (release === "not-found") {
142-
// Cache the negative result to avoid repeated API calls for the same bad tag
143-
await kv.put(key, null, { ttl: LATEST_CACHE_TTL });
144-
return null;
145-
}
146-
if (release) {
147-
const parsed = parseRelease(release);
148-
if (parsed) {
149-
const ttl = tag ? TAGGED_CACHE_TTL : LATEST_CACHE_TTL;
150-
const staleTtl = ttl + 3600;
151-
await Promise.all([
152-
kv.put(key, parsed, { ttl }),
153-
kv.put(staleCacheKey(tag), parsed, { ttl: staleTtl }),
154-
]);
155-
return parsed;
156-
}
157-
}
158-
} catch (err) {
159-
console.error("Failed to fetch release from GitHub:", err);
89+
const release = await resolveRelease(undefined);
90+
if (release) {
91+
await Promise.all([
92+
kv.put(key, release, { ttl: LATEST_CACHE_TTL }),
93+
kv.put(staleCacheKey(undefined), release, { ttl: LATEST_CACHE_TTL + 3600 }),
94+
]);
95+
return release;
16096
}
16197

162-
// Fallback 1: stale KV cache
163-
const stale = await kv.get<CachedRelease>(staleCacheKey(tag));
164-
if (stale) return stale;
165-
166-
// Fallback 2: construct download URLs from tag or npm registry version
167-
if (tag) return buildReleaseFromTag(tag);
168-
const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG);
169-
if (version) return buildReleaseFromTag(`v${version}`);
170-
171-
return null;
98+
// npm unreachable — fall back to stale cache
99+
return kv.get<CachedRelease>(staleCacheKey(undefined));
172100
}
173101

174102
function escapeHtml(s: string): string {
@@ -306,15 +234,14 @@ setupDownloadLink();
306234
export const GET = defineHandler(async (c) => {
307235
const queryArch = c.req.query("arch");
308236
const tag = c.req.query("tag");
309-
const githubToken = c.env.GITHUB_TOKEN as string | undefined;
310237

311238
// When ?arch= is specified, redirect directly (backward-compatible for CLI/curl)
312239
if (queryArch) {
313240
const arch = detectArch(queryArch, c.req.header("user-agent"));
314241
if (arch === null) {
315242
return c.json({ error: "Invalid architecture. Use 'x64' or 'arm64'" }, 400);
316243
}
317-
const release = await getRelease(tag || undefined, githubToken);
244+
const release = await getRelease(tag || undefined);
318245
if (!release) {
319246
return c.json({ error: tag ? `Release '${tag}' not found` : "No release found" }, 404);
320247
}
@@ -326,7 +253,7 @@ export const GET = defineHandler(async (c) => {
326253
}
327254

328255
// Serve the download page with client-side architecture detection
329-
const release = await getRelease(tag || undefined, githubToken);
256+
const release = await getRelease(tag || undefined);
330257
if (!release) {
331258
return c.json({ error: tag ? `Release '${tag}' not found` : "No release found" }, 404);
332259
}

tests/index.test.ts

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

44
describe("detectArch", () => {
55
it("defaults to x64 when no query param or user-agent", () => {
@@ -64,62 +64,6 @@ describe("detectArch", () => {
6464
});
6565
});
6666

67-
describe("parseRelease", () => {
68-
it("parses both x64 and arm64 assets", () => {
69-
const result = parseRelease({
70-
tag_name: "v0.1.17-alpha.0",
71-
assets: [
72-
{
73-
name: "vp-setup-x86_64-pc-windows-msvc.exe",
74-
browser_download_url:
75-
"https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17-alpha.0/vp-setup-x86_64-pc-windows-msvc.exe",
76-
},
77-
{
78-
name: "vp-setup-aarch64-pc-windows-msvc.exe",
79-
browser_download_url:
80-
"https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17-alpha.0/vp-setup-aarch64-pc-windows-msvc.exe",
81-
},
82-
],
83-
});
84-
expect(result).toEqual({
85-
tag: "v0.1.17-alpha.0",
86-
assets: {
87-
x64: "https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17-alpha.0/vp-setup-x86_64-pc-windows-msvc.exe",
88-
arm64:
89-
"https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17-alpha.0/vp-setup-aarch64-pc-windows-msvc.exe",
90-
},
91-
});
92-
});
93-
94-
it("returns null when no matching assets exist", () => {
95-
const result = parseRelease({
96-
tag_name: "v1.0.0",
97-
assets: [
98-
{
99-
name: "some-other-file.tar.gz",
100-
browser_download_url: "https://example.com/other.tar.gz",
101-
},
102-
],
103-
});
104-
expect(result).toBeNull();
105-
});
106-
107-
it("handles release with only x64 asset", () => {
108-
const result = parseRelease({
109-
tag_name: "v0.1.0",
110-
assets: [
111-
{
112-
name: "vp-setup-x86_64-pc-windows-msvc.exe",
113-
browser_download_url: "https://example.com/x64.exe",
114-
},
115-
],
116-
});
117-
expect(result).not.toBeNull();
118-
expect(result!.assets.x64).toBe("https://example.com/x64.exe");
119-
expect(result!.assets.arm64).toBeUndefined();
120-
});
121-
});
122-
12367
describe("buildReleaseFromTag", () => {
12468
it("constructs download URLs from a tag", () => {
12569
const result = buildReleaseFromTag("v0.1.17-alpha.0");
@@ -134,130 +78,56 @@ describe("buildReleaseFromTag", () => {
13478
});
13579
});
13680

137-
describe("fetchRelease", () => {
81+
describe("resolveRelease", () => {
13882
afterEach(() => vi.unstubAllGlobals());
13983

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-
});
84+
it("returns a constructed release for a specific tag without any network call", async () => {
85+
const fetchMock = vi.fn();
86+
vi.stubGlobal("fetch", fetchMock);
16187

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-
});
88+
const result = await resolveRelease("v1.0.0");
17589

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);
90+
expect(result).toEqual(buildReleaseFromTag("v1.0.0"));
91+
expect(fetchMock).not.toHaveBeenCalled();
19292
});
19393

194-
it("default path: resolves via npm latest dist-tag then fetches that GitHub tag", async () => {
195-
const release = {
196-
tag_name: "v0.1.17",
197-
assets: [
198-
{
199-
name: "vp-setup-x86_64-pc-windows-msvc.exe",
200-
browser_download_url:
201-
"https://github.com/voidzero-dev/vite-plus/releases/download/v0.1.17/vp-setup-x86_64-pc-windows-msvc.exe",
202-
},
203-
],
204-
};
94+
it("resolves latest version via npm and returns a constructed release", async () => {
20595
const fetchMock = vi
20696
.fn()
20797
.mockResolvedValueOnce(
20898
new Response(JSON.stringify({ "dist-tags": { latest: "0.1.17" } }), { status: 200 }),
209-
)
210-
.mockResolvedValueOnce(new Response(JSON.stringify(release), { status: 200 }));
99+
);
211100
vi.stubGlobal("fetch", fetchMock);
212101

213-
const result = await fetchRelease(undefined, undefined);
102+
const result = await resolveRelease(undefined);
214103

215-
expect(result).toEqual(release);
216-
expect(fetchMock).toHaveBeenCalledTimes(2);
217-
const secondCallUrl = fetchMock.mock.calls[1][0];
218-
expect(secondCallUrl).toContain("/releases/tags/v0.1.17");
104+
expect(result).toEqual(buildReleaseFromTag("v0.1.17"));
105+
expect(fetchMock).toHaveBeenCalledTimes(1);
219106
});
220107

221-
it("default path: returns null and skips GitHub when npm registry fails", async () => {
108+
it("returns null when npm registry is unreachable", async () => {
222109
const fetchMock = vi
223110
.fn()
224111
.mockResolvedValueOnce(
225112
new Response("Service Unavailable", { status: 503, statusText: "Service Unavailable" }),
226113
);
227114
vi.stubGlobal("fetch", fetchMock);
228115

229-
const result = await fetchRelease(undefined, undefined);
116+
const result = await resolveRelease(undefined);
230117

231118
expect(result).toBeNull();
232-
expect(fetchMock).toHaveBeenCalledTimes(1);
233119
});
234120

235-
it("default path: returns null and skips GitHub when npm has no latest dist-tag", async () => {
121+
it("returns null when npm has no latest dist-tag", async () => {
236122
const fetchMock = vi
237123
.fn()
238124
.mockResolvedValueOnce(
239125
new Response(JSON.stringify({ "dist-tags": { alpha: "0.1.17-alpha.5" } }), { status: 200 }),
240126
);
241127
vi.stubGlobal("fetch", fetchMock);
242128

243-
const result = await fetchRelease(undefined, undefined);
244-
245-
expect(result).toBeNull();
246-
expect(fetchMock).toHaveBeenCalledTimes(1);
247-
});
248-
249-
it('default path: returns null (not "not-found") when GitHub 404s the resolved tag', async () => {
250-
const fetchMock = vi
251-
.fn()
252-
.mockResolvedValueOnce(
253-
new Response(JSON.stringify({ "dist-tags": { latest: "0.1.17" } }), { status: 200 }),
254-
)
255-
.mockResolvedValueOnce(new Response("Not Found", { status: 404, statusText: "Not Found" }));
256-
vi.stubGlobal("fetch", fetchMock);
257-
258-
const result = await fetchRelease(undefined, undefined);
129+
const result = await resolveRelease(undefined);
259130

260131
expect(result).toBeNull();
261-
expect(fetchMock).toHaveBeenCalledTimes(2);
262132
});
263133
});

0 commit comments

Comments
 (0)