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
21 changes: 15 additions & 6 deletions app/api/streak/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,16 +452,23 @@ describe('GET /api/streak', () => {
});

describe('cache-control header', () => {
it('caches until UTC midnight by default, using the value from getSecondsUntilUTCMidnight', async () => {
it('caches SVG responses with static max-age and stale-while-revalidate values', async () => {
const response = await GET(makeRequest({ user: 'octocat' }));
expect(response.headers.get('Cache-Control')).toBe(
'public, max-age=300, stale-while-revalidate=3600'
);
});

it('caches until UTC midnight by default, using the value from getSecondsUntilUTCMidnight', async () => {
const response = await GET(makeRequest({ user: 'octocat', format: 'png' }));
expect(response.headers.get('Cache-Control')).toBe(
'public, max-age=60, s-maxage=3600, stale-while-revalidate=59'
);
});

it('reflects a different time value when the clock changes', async () => {
vi.mocked(getSecondsUntilUTCMidnight).mockReturnValue(7200);
const response = await GET(makeRequest({ user: 'octocat' }));
const response = await GET(makeRequest({ user: 'octocat', format: 'png' }));
expect(response.headers.get('Cache-Control')).toBe(
'public, max-age=60, s-maxage=7200, stale-while-revalidate=59'
);
Expand Down Expand Up @@ -1022,7 +1029,9 @@ describe('GET /api/streak', () => {
});

it('uses getSecondsUntilMidnightInTimezone (not UTC) for the cache TTL when ?tz= is set', async () => {
const response = await GET(makeRequest({ user: 'octocat', tz: 'America/New_York' }));
const response = await GET(
makeRequest({ user: 'octocat', tz: 'America/New_York', format: 'png' })
);

expect(response.headers.get('Cache-Control')).toBe(
'public, max-age=60, s-maxage=7200, stale-while-revalidate=59'
Expand Down Expand Up @@ -1368,7 +1377,7 @@ describe('GET /api/streak', () => {

describe('theme=random cache header', () => {
it('returns no-cache header when ?theme=random is given', async () => {
const response = await GET(makeRequest({ user: 'octocat', theme: 'random' }));
const response = await GET(makeRequest({ user: 'octocat', theme: 'random', format: 'png' }));

expect(response.headers.get('Cache-Control')).toMatch(/public, max-age=60, s-maxage=/);
});
Expand Down Expand Up @@ -1584,13 +1593,13 @@ describe('GET /api/streak', () => {

describe('stale-while-revalidate cache header', () => {
it('contains stale-while-revalidate=59 for normal request', async () => {
const response = await GET(makeRequest({ user: 'octocat' }));
const response = await GET(makeRequest({ user: 'octocat', format: 'png' }));

expect(response.headers.get('Cache-Control')).toContain('stale-while-revalidate=59');
});

it('does NOT contain stale-while-revalidate when ?refresh=true', async () => {
const response = await GET(makeRequest({ user: 'octocat', refresh: 'true' }));
const response = await GET(makeRequest({ user: 'octocat', refresh: 'true', format: 'png' }));

expect(response.headers.get('Cache-Control')).not.toContain('stale-while-revalidate=59');
});
Expand Down
1 change: 1 addition & 0 deletions app/api/streak/route.timezone-boundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ describe('ApiStreakRoute Timezone Normalization & Calendar Boundary Alignment',
makeRequest({
user: 'octocat',
tz: 'Asia/Kolkata',
format: 'png',
})
);

Expand Down
5 changes: 4 additions & 1 deletion app/api/streak/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
calculateStreak,
calculateMonthlyStats,
aggregateCalendars,
convertLocalToUtc,

Check warning on line 16 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'convertLocalToUtc' is defined but never used
chunkDaysIntoWeeks,
normalizeCalendarToTimezone,
isLeapYear,

Check warning on line 19 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'isLeapYear' is defined but never used
daysInYear,
} from '@/lib/calculate';
import {
Expand Down Expand Up @@ -57,7 +57,7 @@

import { validationCache as _vc, normalizeCacheKey, cachedValidation } from './validation-cache';
// Re-alias so existing usages in this file continue to work.
const validationCache = _vc;

Check warning on line 60 in app/api/streak/route.ts

View workflow job for this annotation

GitHub Actions / Format · Lint · Typecheck · Test

'validationCache' is assigned a value but never used

const SVG_CSP_HEADER =
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;";
Expand Down Expand Up @@ -717,11 +717,14 @@
const secondsToMidnight = tzParam
? getSecondsUntilMidnightInTimezone(timezone)
: getSecondsUntilUTCMidnight();
const isPngRoute = request.url.includes('/api/streak/png') || format === 'png';
const cacheControl = isRefreshRequested
? 'no-cache, no-store, must-revalidate'
: isHistoricalYear
? 'public, max-age=31536000, s-maxage=31536000, immutable'
: `public, max-age=60, s-maxage=${secondsToMidnight}, stale-while-revalidate=59`;
: isPngRoute
? `public, max-age=60, s-maxage=${secondsToMidnight}, stale-while-revalidate=59`
: 'public, max-age=300, stale-while-revalidate=3600';

const etag = crypto.createHash('sha256').update(svg).digest('hex');
const weakEtag = `W/"${etag}"`;
Expand Down
2 changes: 1 addition & 1 deletion app/api/streak/tests/dateRange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ describe('GET /api/streak dateRange parameter', () => {
expect(res.status).toBe(200);
expect(res.headers.get('Content-Type')).toBe('image/svg+xml; charset=utf-8');
expect(res.headers.get('Cache-Control')).toBe(
'public, max-age=60, s-maxage=3600, stale-while-revalidate=59'
'public, max-age=300, stale-while-revalidate=3600'
);
});
});
4 changes: 3 additions & 1 deletion app/api/streak/tests/languages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,9 @@ describe('GET /api/streak — languages (lang) parameter', () => {
it('sets Cache-Control and Content-Security-Policy headers for a valid lang parameter', async () => {
const response = await GET(makeRequest({ user: 'octocat', lang: 'de' }));

expect(response.headers.get('Cache-Control')).toMatch(/public, max-age=60, s-maxage=\d+/);
expect(response.headers.get('Cache-Control')).toBe(
'public, max-age=300, stale-while-revalidate=3600'
);
const csp = response.headers.get('Content-Security-Policy');
expect(csp).toContain("default-src 'none'");
expect(csp).toContain("style-src 'unsafe-inline'");
Expand Down
2 changes: 1 addition & 1 deletion app/api/streak/tests/refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe('GET /api/streak - refresh parameter group', () => {

expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBe(
'public, max-age=60, s-maxage=3600, stale-while-revalidate=59'
'public, max-age=300, stale-while-revalidate=3600'
);
expect(response.headers.get('X-Cache-Status')).toBe('HIT');
});
Expand Down
2 changes: 1 addition & 1 deletion app/api/streak/tests/views.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ describe('GET /api/streak view parameter integration', () => {
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('image/svg+xml; charset=utf-8');
expect(response.headers.get('Cache-Control')).toBe(
'public, max-age=60, s-maxage=3600, stale-while-revalidate=59'
'public, max-age=300, stale-while-revalidate=3600'
);
expect(response.headers.get('X-Cache-Status')).toBe('HIT');
});
Expand Down
2 changes: 1 addition & 1 deletion app/api/streak/tests/weekday.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ describe('GET /api/streak?view=weekday', () => {

expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBe(
'public, max-age=60, s-maxage=3600, stale-while-revalidate=59'
'public, max-age=300, stale-while-revalidate=3600'
);
expect(response.headers.get('X-Cache-Status')).toBe('HIT');
});
Expand Down
3 changes: 2 additions & 1 deletion lib/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -454,7 +454,8 @@ describe('fetchGitHubContributions', () => {
const key = cacheKey('contributions', 'fallback-user');
const cachedData = await contributionsCache.get(key);
if (cachedData) {
cachedData.calendar.lastSyncedAt = new Date(Date.now() - 10 * 60 * 1000).toISOString();
cachedData.data.calendar.lastSyncedAt = new Date(Date.now() - 70 * 60 * 1000).toISOString();
cachedData.fetchedAt = Date.now() - 70 * 60 * 1000;
await contributionsCache.set(key, cachedData, 7 * 24 * 60 * 60 * 1000);
}

Expand Down
117 changes: 41 additions & 76 deletions lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,12 @@ type FetchOptions = {

export const GITHUB_CACHE_TTL_MS = Number(process.env.GITHUB_CACHE_TTL_MS ?? String(5 * 60 * 1000));

export const contributionsCache = new DistributedCache<ExtendedContributionData>(1000);
export interface CachedContributions {
data: ExtendedContributionData;
fetchedAt: number;
}

export const contributionsCache = new DistributedCache<CachedContributions>(1000);
const profileCache = new DistributedCache<GitHubUserProfile>(1000);
const reposCache = new DistributedCache<GitHubRepo[]>(500);
const contributedReposCache = new DistributedCache<ContributedRepo[]>(500);
Expand Down Expand Up @@ -901,13 +906,6 @@ export async function fetchGitHubContributions(
process.env.GITHUB_LONG_CACHE_TTL_MS ?? String(7 * 24 * 60 * 60 * 1000)
);

const shouldFetch = (cached: ExtendedContributionData) => {
const now = Date.now();
return cached?.calendar.lastSyncedAt
? now - new Date(cached.calendar.lastSyncedAt).getTime() > GITHUB_CACHE_TTL_MS
: true;
};

const loadWithTimeout = async (): Promise<ExtendedContributionData> => {
const controller = new AbortController();
if (options.signal) {
Expand Down Expand Up @@ -962,83 +960,50 @@ export async function fetchGitHubContributions(
return pending;
};

if (options.signal) {
if (options.bypassCache || options.forceRefresh) {
const revalidateInBackground = (username: string, cacheKey: string) => {
const quota = quotaMonitor.getQuota();
if (quota.remaining < 100) {
logger.warn('Background revalidation paused due to low GitHub API quota', {
username,
remaining: quota.remaining,
});
return;
}

(async () => {
try {
return await loadWithTimeout();
const fresh = await coalescedLoad();
await contributionsCache.set(
cacheKey,
{ data: fresh, fetchedAt: Date.now() },
LONG_CACHE_TTL
);
} catch (err: unknown) {
if (shouldFallbackOnError(err)) {
const staleData = await contributionsCache.get(key);
if (staleData) {
logger.warn('GitHub API fetch failed, falling back to stale cache', {
component: 'GitHub API',
username,
error: err,
});
return {
...staleData,
isOfflineFallback: true,
};
}
return getMockContributions();
}
throw err;
logger.error('Background revalidation failed', { username, error: err });
}
}
const cached = await contributionsCache.get(key);
if (cached !== null && !shouldFetch(cached)) {
return cached;
}
try {
return await loadWithTimeout();
} catch (err: unknown) {
if (shouldFallbackOnError(err)) {
const staleData = await contributionsCache.get(key);
if (staleData) {
logger.warn('GitHub API fetch failed, falling back to stale cache', {
component: 'GitHub API',
username,
error: err,
});
return {
...staleData,
isOfflineFallback: true,
};
}
return getMockContributions();
}
throw err;
}
}
})();
};

if (options.bypassCache || options.forceRefresh) {
try {
const result = await coalescedLoad();
if (options.forceRefresh) {
await contributionsCache.set(key, result, LONG_CACHE_TTL);
if (!options.bypassCache && !options.forceRefresh) {
const cached = await contributionsCache.get(key);
if (cached) {
const age = Date.now() - cached.fetchedAt;
if (age < 60000) {
return cached.data;
}
return result;
} catch (err: unknown) {
if (shouldFallbackOnError(err)) {
const staleData = await contributionsCache.get(key);
if (staleData) {
console.warn(
`[GitHub API] Fetch failed or timed out for "${username}", falling back to stale cache:`,
err
);
return {
...staleData,
isOfflineFallback: true,
};
}
return getMockContributions();
if (age < 3600000) {
revalidateInBackground(username, key);
return cached.data;
}
throw err;
}
}

try {
return await contributionsCache.getOrSet(key, coalescedLoad, LONG_CACHE_TTL, shouldFetch);
const result = await coalescedLoad();
if (!options.bypassCache || options.forceRefresh) {
await contributionsCache.set(key, { data: result, fetchedAt: Date.now() }, LONG_CACHE_TTL);
}
return result;
} catch (err: unknown) {
if (shouldFallbackOnError(err)) {
const staleData = await contributionsCache.get(key);
Expand All @@ -1049,7 +1014,7 @@ export async function fetchGitHubContributions(
error: err,
});
return {
...staleData,
...staleData.data,
isOfflineFallback: true,
};
}
Expand Down
Loading