Skip to content

Commit 6c6d617

Browse files
committed
refactor github stargazers fetching
1 parent 50bee10 commit 6c6d617

File tree

3 files changed

+146
-194
lines changed

3 files changed

+146
-194
lines changed

apps/api/src/routes.ts

Lines changed: 0 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,6 @@ export const API_TAGS = {
2323
PUBLIC: "public",
2424
} as const;
2525

26-
const GITHUB_ORG_REPO = "fastrepl/hyprnote";
27-
28-
interface StargazerCache {
29-
data: { username: string; avatar: string }[];
30-
timestamp: number;
31-
}
32-
33-
let stargazerCache: StargazerCache | null = null;
34-
const CACHE_TTL_MS = 1000 * 60 * 60;
35-
3626
const HealthResponseSchema = z.object({
3727
status: z.string(),
3828
});
@@ -346,118 +336,3 @@ routes.get(
346336
return listenSocketHandler(c, next);
347337
},
348338
);
349-
350-
const StargazersResponseSchema = z.object({
351-
stargazers: z.array(
352-
z.object({
353-
username: z.string(),
354-
avatar: z.string(),
355-
}),
356-
),
357-
});
358-
359-
routes.get(
360-
"/stargazers",
361-
describeRoute({
362-
tags: [API_TAGS.PUBLIC],
363-
summary: "Get GitHub stargazers",
364-
description:
365-
"Returns the most recent GitHub stargazers for the Hyprnote repository. Results are cached for 1 hour.",
366-
responses: {
367-
200: {
368-
description: "List of stargazers",
369-
content: {
370-
"application/json": {
371-
schema: resolver(StargazersResponseSchema),
372-
},
373-
},
374-
},
375-
500: {
376-
description: "Failed to fetch stargazers from GitHub",
377-
content: {
378-
"application/json": {
379-
schema: resolver(z.object({ error: z.string() })),
380-
},
381-
},
382-
},
383-
},
384-
}),
385-
async (c) => {
386-
const now = Date.now();
387-
388-
if (stargazerCache && now - stargazerCache.timestamp < CACHE_TTL_MS) {
389-
return c.json({ stargazers: stargazerCache.data }, 200);
390-
}
391-
392-
const githubToken = process.env.GITHUB_TOKEN;
393-
const githubHeaders: Record<string, string> = {
394-
Accept: "application/vnd.github.v3+json",
395-
"User-Agent": "Hyprnote-API",
396-
};
397-
if (githubToken) {
398-
githubHeaders["Authorization"] = `token ${githubToken}`;
399-
}
400-
401-
try {
402-
const repoResponse = await fetch(
403-
`https://api.github.com/repos/${GITHUB_ORG_REPO}`,
404-
{ headers: githubHeaders },
405-
);
406-
407-
if (!repoResponse.ok) {
408-
throw new Error(`Failed to fetch repo info: ${repoResponse.status}`);
409-
}
410-
411-
const repoData = await repoResponse.json();
412-
const totalStars = repoData.stargazers_count ?? 0;
413-
414-
if (totalStars === 0) {
415-
return c.json({ stargazers: [] }, 200);
416-
}
417-
418-
const count = 512;
419-
const perPage = 100;
420-
const numPages = Math.ceil(Math.min(count, totalStars) / perPage);
421-
const lastPage = Math.ceil(totalStars / perPage);
422-
const startPage = Math.max(1, lastPage - numPages + 1);
423-
424-
const fetchPromises = [];
425-
for (let page = startPage; page <= lastPage; page++) {
426-
fetchPromises.push(
427-
fetch(
428-
`https://api.github.com/repos/${GITHUB_ORG_REPO}/stargazers?per_page=${perPage}&page=${page}`,
429-
{ headers: githubHeaders },
430-
),
431-
);
432-
}
433-
434-
const responses = await Promise.all(fetchPromises);
435-
const allStargazers: { username: string; avatar: string }[] = [];
436-
437-
for (const response of responses) {
438-
if (!response.ok) continue;
439-
const data = await response.json();
440-
for (const user of data) {
441-
allStargazers.push({
442-
username: user.login,
443-
avatar: user.avatar_url,
444-
});
445-
}
446-
}
447-
448-
const result = allStargazers.reverse().slice(0, count);
449-
450-
stargazerCache = {
451-
data: result,
452-
timestamp: now,
453-
};
454-
455-
return c.json({ stargazers: result }, 200);
456-
} catch {
457-
if (stargazerCache) {
458-
return c.json({ stargazers: stargazerCache.data }, 200);
459-
}
460-
return c.json({ error: "Failed to fetch stargazers" }, 500);
461-
}
462-
},
463-
);

apps/web/src/functions/github.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { createServerFn } from "@tanstack/react-start";
2+
3+
import { env } from "../env";
4+
5+
const GITHUB_ORG_REPO = "fastrepl/hyprnote";
6+
const CACHE_TTL_MS = 1000 * 60 * 60;
7+
8+
function getGitHubHeaders(): Record<string, string> {
9+
const headers: Record<string, string> = {
10+
Accept: "application/vnd.github.v3+json",
11+
"User-Agent": "Hyprnote-Web",
12+
};
13+
if (env.GITHUB_TOKEN) {
14+
headers["Authorization"] = `token ${env.GITHUB_TOKEN}`;
15+
}
16+
return headers;
17+
}
18+
19+
interface StatsCache {
20+
data: { stars: number; forks: number };
21+
timestamp: number;
22+
}
23+
24+
let statsCache: StatsCache | null = null;
25+
26+
export const getGitHubStats = createServerFn({ method: "GET" }).handler(
27+
async () => {
28+
const now = Date.now();
29+
30+
if (statsCache && now - statsCache.timestamp < CACHE_TTL_MS) {
31+
return statsCache.data;
32+
}
33+
34+
try {
35+
const response = await fetch(
36+
`https://api.github.com/repos/${GITHUB_ORG_REPO}`,
37+
{ headers: getGitHubHeaders() },
38+
);
39+
40+
if (!response.ok) {
41+
throw new Error(`Failed to fetch repo info: ${response.status}`);
42+
}
43+
44+
const data = await response.json();
45+
const result = {
46+
stars: data.stargazers_count ?? 0,
47+
forks: data.forks_count ?? 0,
48+
};
49+
50+
statsCache = { data: result, timestamp: now };
51+
return result;
52+
} catch {
53+
if (statsCache) {
54+
return statsCache.data;
55+
}
56+
return { stars: 0, forks: 0 };
57+
}
58+
},
59+
);
60+
61+
interface StargazerCache {
62+
data: { username: string; avatar: string }[];
63+
timestamp: number;
64+
}
65+
66+
let stargazerCache: StargazerCache | null = null;
67+
68+
export const getStargazers = createServerFn({ method: "GET" }).handler(
69+
async () => {
70+
const now = Date.now();
71+
72+
if (stargazerCache && now - stargazerCache.timestamp < CACHE_TTL_MS) {
73+
return stargazerCache.data;
74+
}
75+
76+
try {
77+
const headers = getGitHubHeaders();
78+
const repoResponse = await fetch(
79+
`https://api.github.com/repos/${GITHUB_ORG_REPO}`,
80+
{ headers },
81+
);
82+
83+
if (!repoResponse.ok) {
84+
throw new Error(`Failed to fetch repo info: ${repoResponse.status}`);
85+
}
86+
87+
const repoData = await repoResponse.json();
88+
const totalStars = repoData.stargazers_count ?? 0;
89+
90+
if (totalStars === 0) {
91+
return [];
92+
}
93+
94+
const count = 512;
95+
const perPage = 100;
96+
const numPages = Math.ceil(Math.min(count, totalStars) / perPage);
97+
const lastPage = Math.ceil(totalStars / perPage);
98+
const startPage = Math.max(1, lastPage - numPages + 1);
99+
100+
const fetchPromises = [];
101+
for (let page = startPage; page <= lastPage; page++) {
102+
fetchPromises.push(
103+
fetch(
104+
`https://api.github.com/repos/${GITHUB_ORG_REPO}/stargazers?per_page=${perPage}&page=${page}`,
105+
{ headers },
106+
),
107+
);
108+
}
109+
110+
const responses = await Promise.all(fetchPromises);
111+
const allStargazers: { username: string; avatar: string }[] = [];
112+
113+
for (const response of responses) {
114+
if (!response.ok) continue;
115+
const data = await response.json();
116+
for (const user of data) {
117+
allStargazers.push({
118+
username: user.login,
119+
avatar: user.avatar_url,
120+
});
121+
}
122+
}
123+
124+
const result = allStargazers.reverse().slice(0, count);
125+
126+
stargazerCache = {
127+
data: result,
128+
timestamp: now,
129+
};
130+
131+
return result;
132+
} catch {
133+
if (stargazerCache) {
134+
return stargazerCache.data;
135+
}
136+
return [];
137+
}
138+
},
139+
);

apps/web/src/queries.ts

Lines changed: 7 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useQuery } from "@tanstack/react-query";
22

3+
import { getGitHubStats, getStargazers } from "./functions/github";
4+
35
const ORG_REPO = "fastrepl/hyprnote";
46
const LAST_SEEN_STARS = 7032;
57
const LAST_SEEN_FORKS = 432;
@@ -8,13 +10,13 @@ export function useGitHubStats() {
810
return useQuery({
911
queryKey: ["github-stats"],
1012
queryFn: async () => {
11-
const response = await fetch(`https://api.github.com/repos/${ORG_REPO}`);
12-
const data = await response.json();
13+
const stats = await getGitHubStats();
1314
return {
14-
stars: data.stargazers_count ?? LAST_SEEN_STARS,
15-
forks: data.forks_count ?? LAST_SEEN_FORKS,
15+
stars: stats.stars || LAST_SEEN_STARS,
16+
forks: stats.forks || LAST_SEEN_FORKS,
1617
};
1718
},
19+
staleTime: 1000 * 60 * 60,
1820
});
1921
}
2022

@@ -23,74 +25,10 @@ export interface Stargazer {
2325
avatar: string;
2426
}
2527

26-
async function fetchStargazersFromGitHub(): Promise<Stargazer[]> {
27-
const firstResponse = await fetch(
28-
`https://api.github.com/repos/${ORG_REPO}/stargazers?per_page=100`,
29-
);
30-
if (!firstResponse.ok) return [];
31-
32-
const linkHeader = firstResponse.headers.get("Link");
33-
if (!linkHeader) {
34-
const data = await firstResponse.json();
35-
return data.map((user: { login: string; avatar_url: string }) => ({
36-
username: user.login,
37-
avatar: user.avatar_url,
38-
}));
39-
}
40-
41-
const lastMatch = linkHeader.match(/<([^>]+)>;\s*rel="last"/);
42-
if (!lastMatch) {
43-
const data = await firstResponse.json();
44-
return data.map((user: { login: string; avatar_url: string }) => ({
45-
username: user.login,
46-
avatar: user.avatar_url,
47-
}));
48-
}
49-
50-
const lastPageUrl = new URL(lastMatch[1]);
51-
const lastPage = parseInt(lastPageUrl.searchParams.get("page") || "1", 10);
52-
const secondLastPage = Math.max(1, lastPage - 1);
53-
54-
const [lastResponse, secondLastResponse] = await Promise.all([
55-
fetch(lastPageUrl.toString()),
56-
lastPage > 1
57-
? fetch(
58-
`https://api.github.com/repos/${ORG_REPO}/stargazers?per_page=100&page=${secondLastPage}`,
59-
)
60-
: Promise.resolve(null),
61-
]);
62-
63-
if (!lastResponse.ok) return [];
64-
65-
const lastData = await lastResponse.json();
66-
const secondLastData = secondLastResponse?.ok
67-
? await secondLastResponse.json()
68-
: [];
69-
70-
const combined = [...secondLastData, ...lastData];
71-
return combined
72-
.reverse()
73-
.slice(0, 200)
74-
.map((user: { login: string; avatar_url: string }) => ({
75-
username: user.login,
76-
avatar: user.avatar_url,
77-
}));
78-
}
79-
8028
export function useGitHubStargazers() {
8129
return useQuery({
8230
queryKey: ["github-stargazers"],
83-
queryFn: async (): Promise<Stargazer[]> => {
84-
const response = await fetch("https://api.hyprnote.com/stargazers").catch(
85-
() => null,
86-
);
87-
if (response?.ok) {
88-
const data = await response.json();
89-
return data.stargazers;
90-
}
91-
92-
return fetchStargazersFromGitHub().catch(() => []);
93-
},
31+
queryFn: () => getStargazers(),
9432
staleTime: 1000 * 60 * 60,
9533
});
9634
}

0 commit comments

Comments
 (0)