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
8 changes: 5 additions & 3 deletions src/lib/components/GitHubStarBadge.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
})
Expand Down
49 changes: 49 additions & 0 deletions src/routes/api/github/stars/+server.ts
Original file line number Diff line number Diff line change
@@ -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' } }
);
};
53 changes: 53 additions & 0 deletions src/routes/api/github/stars/server.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading