Skip to content

Commit cda7959

Browse files
committed
feat: add in-memory cache for GitHub API responses
1 parent 66c9804 commit cda7959

6 files changed

Lines changed: 211 additions & 16 deletions

File tree

app/api/github/route.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,22 @@ import { getFullDashboardData } from '@/lib/github';
44
export async function GET(request: Request) {
55
const { searchParams } = new URL(request.url);
66
const username = searchParams.get('username');
7+
const refresh = searchParams.get('refresh') === 'true';
78

89
if (!username) {
910
return NextResponse.json({ error: 'Username is required' }, { status: 400 });
1011
}
1112

1213
try {
13-
const data = await getFullDashboardData(username);
14+
const data = await getFullDashboardData(username, { bypassCache: refresh });
15+
const cacheControl = refresh
16+
? 'no-cache, no-store, must-revalidate'
17+
: 's-maxage=3600, stale-while-revalidate';
1418

1519
return NextResponse.json(data, {
1620
status: 200,
1721
headers: {
18-
'Cache-Control': 's-maxage=3600, stale-while-revalidate',
22+
'Cache-Control': cacheControl,
1923
},
2024
});
2125
} catch (error: unknown) {

app/api/streak/route.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ describe('GET /api/streak', () => {
9696
it('forwards the username to fetchGitHubContributions', async () => {
9797
await GET(makeRequest({ user: 'octocat' }));
9898

99-
expect(fetchGitHubContributions).toHaveBeenCalledWith('octocat');
99+
expect(fetchGitHubContributions).toHaveBeenCalledWith('octocat', { bypassCache: false });
100100
});
101101

102102
it('embeds the username (uppercased) in the SVG title', async () => {
@@ -134,6 +134,12 @@ describe('GET /api/streak', () => {
134134
expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate');
135135
});
136136

137+
it('passes bypassCache=true when refresh=true', async () => {
138+
await GET(makeRequest({ user: 'octocat', refresh: 'true' }));
139+
140+
expect(fetchGitHubContributions).toHaveBeenCalledWith('octocat', { bypassCache: true });
141+
});
142+
137143
it('keeps normal caching when refresh is "false"', async () => {
138144
// Only the exact string "true" disables caching.
139145
const response = await GET(makeRequest({ user: 'octocat', refresh: 'false' }));

app/api/streak/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,15 @@ export async function GET(request: Request) {
3838
font,
3939
};
4040

41-
const calendar = await fetchGitHubContributions(user);
41+
const refresh = searchParams.get('refresh') === 'true';
42+
43+
const calendar = await fetchGitHubContributions(user, { bypassCache: refresh });
4244
const stats = calculateStreak(calendar);
4345

4446
const svg = generateSVG(stats, params, calendar);
4547

4648
// 4. Calculate Cache Control (Reset at UTC Midnight)
4749
const secondsToMidnight = getSecondsUntilUTCMidnight();
48-
const refresh = searchParams.get('refresh') === 'true';
4950
const cacheControl = refresh
5051
? 'no-cache, no-store, must-revalidate'
5152
: `public, s-maxage=${secondsToMidnight}, stale-while-revalidate=86400`;

lib/cache.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
type CacheItem<T> = {
2+
value: T;
3+
expiresAt: number;
4+
};
5+
6+
export class TTLCache<T> {
7+
private store = new Map<string, CacheItem<T>>();
8+
9+
get(key: string): T | null {
10+
const hit = this.store.get(key);
11+
if (!hit) return null;
12+
13+
if (Date.now() > hit.expiresAt) {
14+
this.store.delete(key);
15+
return null;
16+
}
17+
18+
return hit.value;
19+
}
20+
21+
set(key: string, value: T, ttlMs: number): void {
22+
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
23+
}
24+
25+
clear(): void {
26+
this.store.clear();
27+
}
28+
}

lib/github.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
fetchUserProfile,
66
fetchUserRepos,
77
getFullDashboardData,
8+
clearGitHubApiCacheForTests,
9+
GITHUB_CACHE_TTL_MS,
810
} from './github';
911
import type { ContributionCalendar } from '../types';
1012

@@ -28,6 +30,14 @@ function mockResponse(body: unknown, status = 200): Response {
2830
});
2931
}
3032

33+
beforeEach(() => {
34+
clearGitHubApiCacheForTests();
35+
});
36+
37+
afterEach(() => {
38+
clearGitHubApiCacheForTests();
39+
});
40+
3141
describe('fetchGitHubContributions', () => {
3242
beforeEach(() => {
3343
vi.spyOn(global, 'fetch');
@@ -247,3 +257,67 @@ describe('getFullDashboardData', () => {
247257
await expect(getFullDashboardData('octocat')).rejects.toThrow('An unknown error occurred');
248258
});
249259
});
260+
261+
describe('GitHub API cache behavior', () => {
262+
beforeEach(() => {
263+
clearGitHubApiCacheForTests();
264+
vi.spyOn(global, 'fetch');
265+
vi.useRealTimers();
266+
});
267+
268+
afterEach(() => {
269+
vi.useRealTimers();
270+
vi.restoreAllMocks();
271+
clearGitHubApiCacheForTests();
272+
});
273+
274+
it('cache hit: second contributions call uses cached value', async () => {
275+
vi.mocked(fetch).mockResolvedValue(
276+
mockResponse({
277+
data: {
278+
user: { contributionsCollection: { contributionCalendar: mockCalendar } },
279+
},
280+
})
281+
);
282+
283+
await fetchGitHubContributions('octocat');
284+
await fetchGitHubContributions('octocat');
285+
286+
expect(fetch).toHaveBeenCalledTimes(1);
287+
});
288+
289+
it('refresh bypass: bypassCache=true forces a fresh fetch', async () => {
290+
vi.mocked(fetch).mockResolvedValue(
291+
mockResponse({
292+
data: {
293+
user: { contributionsCollection: { contributionCalendar: mockCalendar } },
294+
},
295+
})
296+
);
297+
298+
await fetchGitHubContributions('octocat');
299+
await fetchGitHubContributions('octocat', { bypassCache: true });
300+
301+
expect(fetch).toHaveBeenCalledTimes(2);
302+
});
303+
304+
it('cache expiry: expired entry triggers a new fetch', async () => {
305+
vi.useFakeTimers();
306+
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
307+
308+
vi.mocked(fetch).mockResolvedValue(
309+
mockResponse({
310+
data: {
311+
user: { contributionsCollection: { contributionCalendar: mockCalendar } },
312+
},
313+
})
314+
);
315+
316+
await fetchGitHubContributions('octocat');
317+
318+
vi.setSystemTime(Date.now() + GITHUB_CACHE_TTL_MS + 1);
319+
await fetchGitHubContributions('octocat');
320+
321+
expect(fetch).toHaveBeenCalledTimes(2);
322+
});
323+
});

lib/github.ts

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import type { ContributionCalendar } from '../types';
44
import { calculateStreak } from './calculate';
5+
import { TTLCache } from './cache';
56

67
interface GitHubRepo {
78
stargazers_count: number;
@@ -22,12 +23,55 @@ type GitHubContributionResponse = {
2223
errors?: Array<{ message: string }>;
2324
};
2425

26+
type FetchOptions = {
27+
bypassCache?: boolean;
28+
};
29+
30+
export const GITHUB_CACHE_TTL_MS = 5 * 60 * 1000;
31+
32+
interface GitHubUserProfile {
33+
login: string;
34+
name: string | null;
35+
avatar_url: string;
36+
public_repos: number;
37+
followers: number;
38+
following: number;
39+
created_at: string;
40+
bio: string | null;
41+
location: string | null;
42+
plan?: { name?: string } | null;
43+
}
44+
45+
const contributionsCache = new TTLCache<ContributionCalendar>();
46+
const profileCache = new TTLCache<GitHubUserProfile>();
47+
const reposCache = new TTLCache<GitHubRepo[]>();
48+
49+
function cacheKey(kind: 'contributions' | 'profile' | 'repos', username: string): string {
50+
return `${kind}:${username.toLowerCase()}`;
51+
}
52+
53+
export function clearGitHubApiCacheForTests(): void {
54+
contributionsCache.clear();
55+
profileCache.clear();
56+
reposCache.clear();
57+
}
58+
2559
const getHeaders = () => ({
2660
Authorization: `bearer ${process.env.GITHUB_PAT || process.env.GITHUB_TOKEN}`,
2761
'Content-Type': 'application/json',
2862
});
2963

30-
export async function fetchGitHubContributions(username: string): Promise<ContributionCalendar> {
64+
export async function fetchGitHubContributions(
65+
username: string,
66+
options: FetchOptions = {}
67+
): Promise<ContributionCalendar> {
68+
const key = cacheKey('contributions', username);
69+
70+
if (!options.bypassCache) {
71+
const cached = contributionsCache.get(key);
72+
if (cached) return cached;
73+
}
74+
3175
const query = `
3276
query($login: String!) {
3377
user(login: $login) {
@@ -51,7 +95,7 @@ export async function fetchGitHubContributions(username: string): Promise<Contri
5195
method: 'POST',
5296
headers: getHeaders(),
5397
body: JSON.stringify({ query, variables: { login: username } }),
54-
cache: 'no-store', // Cache handled at the API route
98+
cache: 'no-store', // Cache handled by our in-memory layer + API route headers
5599
});
56100

57101
if (!res.ok) {
@@ -69,10 +113,26 @@ export async function fetchGitHubContributions(username: string): Promise<Contri
69113
throw new Error(`GitHub user "${username}" not found`);
70114
}
71115

72-
return data.data.user.contributionsCollection.contributionCalendar;
116+
const calendar = data.data.user.contributionsCollection.contributionCalendar;
117+
118+
if (!options.bypassCache) {
119+
contributionsCache.set(key, calendar, GITHUB_CACHE_TTL_MS);
120+
}
121+
122+
return calendar;
73123
}
74124

75-
export async function fetchUserProfile(username: string) {
125+
export async function fetchUserProfile(
126+
username: string,
127+
options: FetchOptions = {}
128+
): Promise<GitHubUserProfile> {
129+
const key = cacheKey('profile', username);
130+
131+
if (!options.bypassCache) {
132+
const cached = profileCache.get(key);
133+
if (cached) return cached;
134+
}
135+
76136
const res = await fetch(`${GITHUB_REST_URL}/users/${username}`, {
77137
headers: getHeaders(),
78138
cache: 'no-store',
@@ -83,10 +143,26 @@ export async function fetchUserProfile(username: string) {
83143
throw new Error(`GitHub REST API error: ${res.status}`);
84144
}
85145

86-
return res.json();
146+
const profile = (await res.json()) as GitHubUserProfile;
147+
148+
if (!options.bypassCache) {
149+
profileCache.set(key, profile, GITHUB_CACHE_TTL_MS);
150+
}
151+
152+
return profile;
87153
}
88154

89-
export async function fetchUserRepos(username: string) {
155+
export async function fetchUserRepos(
156+
username: string,
157+
options: FetchOptions = {}
158+
): Promise<GitHubRepo[]> {
159+
const key = cacheKey('repos', username);
160+
161+
if (!options.bypassCache) {
162+
const cached = reposCache.get(key);
163+
if (cached) return cached;
164+
}
165+
90166
const res = await fetch(`${GITHUB_REST_URL}/users/${username}/repos?per_page=100&sort=pushed`, {
91167
headers: getHeaders(),
92168
cache: 'no-store',
@@ -96,15 +172,21 @@ export async function fetchUserRepos(username: string) {
96172
throw new Error(`GitHub REST API error: ${res.status}`);
97173
}
98174

99-
return res.json();
175+
const repos = (await res.json()) as GitHubRepo[];
176+
177+
if (!options.bypassCache) {
178+
reposCache.set(key, repos, GITHUB_CACHE_TTL_MS);
179+
}
180+
181+
return repos;
100182
}
101183

102-
export async function getFullDashboardData(username: string) {
184+
export async function getFullDashboardData(username: string, options: FetchOptions = {}) {
103185
try {
104186
const [profileData, reposData, calendarData] = await Promise.all([
105-
fetchUserProfile(username),
106-
fetchUserRepos(username),
107-
fetchGitHubContributions(username),
187+
fetchUserProfile(username, options),
188+
fetchUserRepos(username, options),
189+
fetchGitHubContributions(username, options),
108190
]);
109191

110192
// Pre-compute streak + stars early so developerScore can use them

0 commit comments

Comments
 (0)