Files
nexu-io--open-design/apps/web/src/components/useGithubStars.ts
T
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

159 lines
4.9 KiB
TypeScript

// Shared GitHub star-count hook backing the topbar pill
// (`GithubStarBadge`). The browser talks only to the local daemon,
// which caches GitHub metadata and can return stale-on-error values
// when the upstream API flakes. This keeps Electron / prerelease builds
// out of the business of making unauthenticated GitHub requests from
// the renderer.
import { useEffect, useState } from 'react';
import type { OpenDesignGithubRepoResponse } from '@open-design/contracts';
const API = '/api/github/open-design';
const REPO = 'https://github.com/nexu-io/open-design';
const LS_KEY = 'open-design:gh-stars';
const FAILURE_LS_KEY = 'open-design:gh-stars:last-failure';
export const GITHUB_STARS_FALLBACK_LABEL = '40K+';
// One-hour soft cache — long enough to dodge GitHub's 60/hr
// unauthenticated quota when the same user reopens the app several
// times in a session, short enough that growing star counts still
// surface within a single working day.
const CACHE_TTL_MS = 60 * 60 * 1000;
const FAILURE_COOLDOWN_MS = 5 * 60 * 1000;
type CachedStars = { count: number; ts: number };
let memoryCache: CachedStars | null = null;
let memoryFailureAt: number | null = null;
function readPersistedCache(): CachedStars | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(LS_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<CachedStars>;
if (typeof parsed.count !== 'number' || typeof parsed.ts !== 'number') {
return null;
}
return { count: parsed.count, ts: parsed.ts };
} catch {
return null;
}
}
function writePersistedCache(value: CachedStars): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(LS_KEY, JSON.stringify(value));
} catch {
// Quota errors are fine to swallow — the in-memory cache still
// keeps subsequent renders cheap within this tab.
}
}
function readPersistedFailureAt(): number | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(FAILURE_LS_KEY);
if (!raw) return null;
const ts = Number(raw);
return Number.isFinite(ts) && ts > 0 ? ts : null;
} catch {
return null;
}
}
function writePersistedFailureAt(ts: number): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(FAILURE_LS_KEY, String(ts));
} catch {
// Same as the star cache: storage failures should not turn a
// quiet offline badge into a renderer error source.
}
}
function clearPersistedFailureAt(): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.removeItem(FAILURE_LS_KEY);
} catch {
// Ignore storage failures; the in-memory cooldown still covers
// this renderer session.
}
}
function rememberFetchFailure(): void {
const failedAt = Date.now();
memoryFailureAt = failedAt;
writePersistedFailureAt(failedAt);
}
export function formatStars(count: number): string {
if (!Number.isFinite(count) || count <= 0) return '0';
if (count < 1000) return String(count);
return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}K`;
}
export const GITHUB_REPO_URL = REPO;
export function useGithubStars(): number | null {
const [count, setCount] = useState<number | null>(() => {
if (memoryCache) return memoryCache.count;
const persisted = readPersistedCache();
if (persisted) memoryCache = persisted;
return persisted ? persisted.count : null;
});
useEffect(() => {
const now = Date.now();
const cached = memoryCache ?? readPersistedCache();
if (cached && now - cached.ts < CACHE_TTL_MS) {
memoryCache = cached;
setCount(cached.count);
return;
}
const failedAt = memoryFailureAt ?? readPersistedFailureAt();
if (failedAt && now - failedAt < FAILURE_COOLDOWN_MS) {
memoryFailureAt = failedAt;
return;
}
const ctrl = new AbortController();
(async () => {
try {
const res = await fetch(API, {
signal: ctrl.signal,
});
if (!res.ok) {
rememberFetchFailure();
return;
}
const data = (await res.json()) as Partial<OpenDesignGithubRepoResponse>;
if (typeof data.stargazers_count !== 'number') {
rememberFetchFailure();
return;
}
const next: CachedStars = {
count: data.stargazers_count,
ts: Date.now(),
};
memoryCache = next;
memoryFailureAt = null;
writePersistedCache(next);
clearPersistedFailureAt();
setCount(next.count);
} catch (error) {
if (ctrl.signal.aborted || (error instanceof Error && error.name === 'AbortError')) {
return;
}
// Network failures and rate-limit 403s both land here. The
// caller keeps rendering its previous (or fallback) count.
rememberFetchFailure();
}
})();
return () => ctrl.abort();
}, []);
return count;
}