Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,26 @@ jobs:
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
const marker = '<!-- staging-deploy -->';
const body = `${marker}\n✅ Staging deployment successful!\n\nPreview: https://vp-setup-staging.void.app/\nCommit: ${context.sha}`;
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: '✅ Staging deployment successful!\n\nPreview: https://vp-setup-staging.void.app/'
})
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ Deployed to [Cloudflare Workers](https://workers.cloudflare.com/) via [Void](htt

### Environment variables

| Variable | Required | Description |
| -------------- | -------- | ---------------------------------------------------------- |
| `GITHUB_TOKEN` | No | GitHub token for higher API rate limits (60/hr -> 5000/hr) |
| `VOID_TOKEN` | Yes (CI) | Void deployment token |
| Variable | Required | Description |
| ------------ | -------- | --------------------- |
| `VOID_TOKEN` | Yes (CI) | Void deployment token |
134 changes: 29 additions & 105 deletions routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ const ASSET_NAMES: Record<Arch, string> = {
arm64: "vp-setup-aarch64-pc-windows-msvc.exe",
};
const LATEST_CACHE_TTL = 300; // 5 minutes
const TAGGED_CACHE_TTL = 86400; // 24 hours
const DEFAULT_DIST_TAG = "latest";

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

interface GitHubRelease {
tag_name: string;
assets: Array<{ name: string; browser_download_url: string }>;
}

export function detectArch(
queryArch: string | undefined,
userAgent: string | undefined,
Expand All @@ -40,58 +34,6 @@ export function detectArch(
return "x64";
}

export function parseRelease(release: GitHubRelease): CachedRelease | null {
const assets: Partial<Record<Arch, string>> = {};
for (const asset of release.assets) {
if (asset.name === ASSET_NAMES.x64) assets.x64 = asset.browser_download_url;
if (asset.name === ASSET_NAMES.arm64) assets.arm64 = asset.browser_download_url;
}
if (!assets.x64 && !assets.arm64) return null;
return { tag: release.tag_name, assets };
}

async function fetchGitHub(path: string, githubToken: string | undefined): Promise<Response> {
const headers: Record<string, string> = {
Accept: "application/vnd.github.v3+json",
"User-Agent": "vp-setup-exe-downloader",
};
if (githubToken) headers.Authorization = `Bearer ${githubToken}`;
return fetch(`https://api.github.com${path}`, { headers });
}

// "not-found" means the tag definitively doesn't exist (GitHub 404).
// null means the API failed (rate limit, network error) — fallbacks may still work.
export async function fetchRelease(
tag: string | undefined,
githubToken: string | undefined,
): Promise<GitHubRelease | null | "not-found"> {
if (tag) {
const res = await fetchGitHub(
`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tags/${tag}`,
githubToken,
);
if (!res.ok) {
console.error(`GitHub API error: ${res.status} ${res.statusText} for tag ${tag}`);
return res.status === 404 ? "not-found" : null;
}
return (await res.json()) as GitHubRelease;
}

const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG);
if (!version) return null;
const res = await fetchGitHub(
`/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/tags/v${version}`,
githubToken,
);
if (!res.ok) {
console.error(`GitHub API error: ${res.status} ${res.statusText} for default tag v${version}`);
// Treat all failures (incl. 404) as transient so getRelease doesn't cache a negative
// under `release:latest` when npm/GitHub are momentarily out of sync.
return null;
}
return (await res.json()) as GitHubRelease;
}

export function buildReleaseFromTag(tag: string): CachedRelease {
const base = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/${tag}`;
return {
Expand Down Expand Up @@ -120,58 +62,36 @@ async function fetchNpmDistTagVersion(distTag: string): Promise<string | null> {
}
}

function cacheKey(tag: string | undefined): string {
return tag ? `release:tag:${tag}` : "release:latest";
}
const LATEST_CACHE_KEY = "release:latest";
const LATEST_STALE_KEY = "release:latest:stale";

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

async function getRelease(
tag: string | undefined,
githubToken: string | undefined,
): Promise<CachedRelease | null> {
const key = cacheKey(tag);
const cached = await kv.get<CachedRelease>(key);
// "Latest" path: use KV cache to avoid hitting npm on every request
const cached = await kv.get<CachedRelease>(LATEST_CACHE_KEY);
if (cached) return cached;

try {
const release = await fetchRelease(tag, githubToken);
if (release === "not-found") {
// Cache the negative result to avoid repeated API calls for the same bad tag
await kv.put(key, null, { ttl: LATEST_CACHE_TTL });
return null;
}
if (release) {
const parsed = parseRelease(release);
if (parsed) {
const ttl = tag ? TAGGED_CACHE_TTL : LATEST_CACHE_TTL;
const staleTtl = ttl + 3600;
await Promise.all([
kv.put(key, parsed, { ttl }),
kv.put(staleCacheKey(tag), parsed, { ttl: staleTtl }),
]);
return parsed;
}
const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG);
if (version) {
const release = buildReleaseFromTag(`v${version}`);
try {
await Promise.all([
kv.put(LATEST_CACHE_KEY, release, { ttl: LATEST_CACHE_TTL }),
kv.put(LATEST_STALE_KEY, release, { ttl: LATEST_CACHE_TTL + 3600 }),
]);
} catch (err) {
console.error("KV write failed:", err);
}
} catch (err) {
console.error("Failed to fetch release from GitHub:", err);
return release;
}

// Fallback 1: stale KV cache
const stale = await kv.get<CachedRelease>(staleCacheKey(tag));
if (stale) return stale;

// Fallback 2: construct download URLs from tag or npm registry version
if (tag) return buildReleaseFromTag(tag);
const version = await fetchNpmDistTagVersion(DEFAULT_DIST_TAG);
if (version) return buildReleaseFromTag(`v${version}`);

return null;
// npm unreachable — fall back to stale cache
return kv.get<CachedRelease>(LATEST_STALE_KEY);
}

function escapeHtml(s: string): string {
export function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
Expand Down Expand Up @@ -291,7 +211,12 @@ async function setupDownloadLink() {
mainBtn.textContent = "Download for Windows (ARM64)";

if (altEl && x64Url) {
altEl.innerHTML = 'Also available: <a href="' + x64Url + '" download>Windows x64</a>';
var link = document.createElement("a");
link.href = x64Url;
link.download = "";
link.textContent = "Windows x64";
altEl.textContent = "Also available: ";
altEl.appendChild(link);
}
}

Expand All @@ -306,15 +231,14 @@ setupDownloadLink();
export const GET = defineHandler(async (c) => {
const queryArch = c.req.query("arch");
const tag = c.req.query("tag");
const githubToken = c.env.GITHUB_TOKEN as string | undefined;

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

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