Skip to content

Commit 28b8f38

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 28b8f38

3 files changed

Lines changed: 24 additions & 294 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: 19 additions & 104 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,58 +34,6 @@ 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-
80-
const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG);
81-
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;
93-
}
94-
9537
export function buildReleaseFromTag(tag: string): CachedRelease {
9638
const base = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/${tag}`;
9739
return {
@@ -120,55 +62,29 @@ async function fetchNpmDistTagVersion(distTag: string): Promise<string | null> {
12062
}
12163
}
12264

123-
function cacheKey(tag: string | undefined): string {
124-
return tag ? `release:tag:${tag}` : "release:latest";
125-
}
65+
const LATEST_CACHE_KEY = "release:latest";
66+
const LATEST_STALE_KEY = "release:latest:stale";
12667

127-
function staleCacheKey(tag: string | undefined): string {
128-
return `${cacheKey(tag)}:stale`;
129-
}
68+
async function getRelease(tag: string | undefined): Promise<CachedRelease | null> {
69+
// Tags are immutable — construct the download URL directly, no network or cache needed
70+
if (tag) return buildReleaseFromTag(tag);
13071

131-
async function getRelease(
132-
tag: string | undefined,
133-
githubToken: string | undefined,
134-
): Promise<CachedRelease | null> {
135-
const key = cacheKey(tag);
136-
const cached = await kv.get<CachedRelease>(key);
72+
// "Latest" path: use KV cache to avoid hitting npm on every request
73+
const cached = await kv.get<CachedRelease>(LATEST_CACHE_KEY);
13774
if (cached) return cached;
13875

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);
160-
}
161-
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);
16876
const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG);
169-
if (version) return buildReleaseFromTag(`v${version}`);
77+
if (version) {
78+
const release = buildReleaseFromTag(`v${version}`);
79+
await Promise.all([
80+
kv.put(LATEST_CACHE_KEY, release, { ttl: LATEST_CACHE_TTL }),
81+
kv.put(LATEST_STALE_KEY, release, { ttl: LATEST_CACHE_TTL + 3600 }),
82+
]);
83+
return release;
84+
}
17085

171-
return null;
86+
// npm unreachable — fall back to stale cache
87+
return kv.get<CachedRelease>(LATEST_STALE_KEY);
17288
}
17389

17490
function escapeHtml(s: string): string {
@@ -306,15 +222,14 @@ setupDownloadLink();
306222
export const GET = defineHandler(async (c) => {
307223
const queryArch = c.req.query("arch");
308224
const tag = c.req.query("tag");
309-
const githubToken = c.env.GITHUB_TOKEN as string | undefined;
310225

311226
// When ?arch= is specified, redirect directly (backward-compatible for CLI/curl)
312227
if (queryArch) {
313228
const arch = detectArch(queryArch, c.req.header("user-agent"));
314229
if (arch === null) {
315230
return c.json({ error: "Invalid architecture. Use 'x64' or 'arm64'" }, 400);
316231
}
317-
const release = await getRelease(tag || undefined, githubToken);
232+
const release = await getRelease(tag || undefined);
318233
if (!release) {
319234
return c.json({ error: tag ? `Release '${tag}' not found` : "No release found" }, 404);
320235
}
@@ -326,7 +241,7 @@ export const GET = defineHandler(async (c) => {
326241
}
327242

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

tests/index.test.ts

Lines changed: 2 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
2-
import { buildReleaseFromTag, detectArch, fetchRelease, parseRelease } from "../routes/index";
1+
import { describe, expect, it } from "vite-plus/test";
2+
import { buildReleaseFromTag, detectArch } 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");
@@ -133,131 +77,3 @@ describe("buildReleaseFromTag", () => {
13377
});
13478
});
13579
});
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-
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-
};
205-
const fetchMock = vi
206-
.fn()
207-
.mockResolvedValueOnce(
208-
new Response(JSON.stringify({ "dist-tags": { latest: "0.1.17" } }), { status: 200 }),
209-
)
210-
.mockResolvedValueOnce(new Response(JSON.stringify(release), { status: 200 }));
211-
vi.stubGlobal("fetch", fetchMock);
212-
213-
const result = await fetchRelease(undefined, undefined);
214-
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");
219-
});
220-
221-
it("default path: returns null and skips GitHub when npm registry fails", async () => {
222-
const fetchMock = vi
223-
.fn()
224-
.mockResolvedValueOnce(
225-
new Response("Service Unavailable", { status: 503, statusText: "Service Unavailable" }),
226-
);
227-
vi.stubGlobal("fetch", fetchMock);
228-
229-
const result = await fetchRelease(undefined, undefined);
230-
231-
expect(result).toBeNull();
232-
expect(fetchMock).toHaveBeenCalledTimes(1);
233-
});
234-
235-
it("default path: returns null and skips GitHub when npm has no latest dist-tag", async () => {
236-
const fetchMock = vi
237-
.fn()
238-
.mockResolvedValueOnce(
239-
new Response(JSON.stringify({ "dist-tags": { alpha: "0.1.17-alpha.5" } }), { status: 200 }),
240-
);
241-
vi.stubGlobal("fetch", fetchMock);
242-
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);
259-
260-
expect(result).toBeNull();
261-
expect(fetchMock).toHaveBeenCalledTimes(2);
262-
});
263-
});

0 commit comments

Comments
 (0)