diff --git a/src/lib/components/GitHubStarBadge.svelte b/src/lib/components/GitHubStarBadge.svelte index dcae020..a7251a8 100644 --- a/src/lib/components/GitHubStarBadge.svelte +++ b/src/lib/components/GitHubStarBadge.svelte @@ -5,11 +5,13 @@ let loading = $state(true); onMount(() => { - fetch('https://api.github.com/repos/openbootdotdev/openboot') + // Same-origin endpoint that proxies + edge-caches the GitHub count, so we + // don't burn each visitor's 60 req/hour unauthenticated GitHub API limit. + fetch('/api/github/stars') .then((r) => r.json()) .then((data) => { - if (data.stargazers_count) { - starCount = data.stargazers_count; + if (data.stars) { + starCount = data.stars; } loading = false; }) diff --git a/src/routes/api/github/stars/+server.ts b/src/routes/api/github/stars/+server.ts new file mode 100644 index 0000000..9776c4b --- /dev/null +++ b/src/routes/api/github/stars/+server.ts @@ -0,0 +1,49 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +// Server-side proxy for the repo star count. Browsers used to hit the GitHub +// API directly, which is rate-limited to 60 req/hour per visitor IP — heavy +// traffic from one IP made the count silently disappear. Fetching here and +// caching at the Cloudflare edge means GitHub is hit ~once per hour per colo, +// no matter how many visitors load the page. + +const REPO = 'openbootdotdev/openboot'; +const CACHE_SECONDS = 3600; +// Shown only if GitHub is unreachable, so the badge always renders something. +const FALLBACK_STARS = 260; + +interface GitHubRepo { + stargazers_count?: number; +} + +export const GET: RequestHandler = async () => { + try { + const res = await fetch(`https://api.github.com/repos/${REPO}`, { + headers: { + // GitHub rejects API requests without a User-Agent. + 'User-Agent': 'openboot.dev', + Accept: 'application/vnd.github+json' + }, + cf: { cacheTtl: CACHE_SECONDS, cacheEverything: true } + } as RequestInit); + + if (res.ok) { + const data = (await res.json()) as GitHubRepo; + if (typeof data.stargazers_count === 'number') { + return json( + { stars: data.stargazers_count }, + { headers: { 'Cache-Control': `public, max-age=${CACHE_SECONDS}` } } + ); + } + } + } catch { + // fall through to the fallback below + } + + // Upstream failed or returned an unexpected shape — serve a sane default and + // retry sooner than the happy-path cache window. + return json( + { stars: FALLBACK_STARS, stale: true }, + { headers: { 'Cache-Control': 'public, max-age=300' } } + ); +}; diff --git a/src/routes/api/github/stars/server.test.ts b/src/routes/api/github/stars/server.test.ts new file mode 100644 index 0000000..29f0307 --- /dev/null +++ b/src/routes/api/github/stars/server.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { GET } from './+server'; + +afterEach(() => vi.unstubAllGlobals()); + +// The handler ignores the request event, so an empty object is enough. +const call = () => GET({} as any); + +describe('GET /api/github/stars', () => { + it('returns the live star count from GitHub', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify({ stargazers_count: 260 }), { status: 200 })) + ); + + const res = await call(); + const data = (await res.json()) as { stars: number; stale?: boolean }; + + expect(res.status).toBe(200); + expect(data.stars).toBe(260); + expect(data.stale).toBeUndefined(); + expect(res.headers.get('Cache-Control')).toBe('public, max-age=3600'); + }); + + it('falls back when GitHub is unreachable', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('network down'); + }) + ); + + const res = await call(); + const data = (await res.json()) as { stars: number; stale?: boolean }; + + expect(data.stars).toBeGreaterThan(0); + expect(data.stale).toBe(true); + expect(res.headers.get('Cache-Control')).toBe('public, max-age=300'); + }); + + it('falls back on a non-ok response (e.g. rate limited)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('rate limit exceeded', { status: 403 })) + ); + + const res = await call(); + const data = (await res.json()) as { stars: number; stale?: boolean }; + + expect(data.stale).toBe(true); + expect(data.stars).toBeGreaterThan(0); + }); +});