Feat(github-stars): self-host the repo star count, drop shields.io

shields.io's stars badge kept failing on GitHub token-pool exhaustion
("Unable to select the next GitHub token from pool"). Serve the count
from our own backend instead and render the badge ourselves.

- api/github-stars.js: fetch stargazers_count unauthenticated (no token),
  edge-cached for a day in backend-server.js. Behind Cloudflare the origin
  hits GitHub at most once per cache window, well under the 60 req/hour
  unauthenticated limit; error responses stay no-store so failures aren't
  cached.
- Nav.vue: replace the shields.io <img> with our own GitHub badge on
  desktop and add the count to the mobile drawer's "Star on GitHub" row,
  both fed by /api/github-stars (fetched on mount). Count is hidden until
  it lands / on error, so the link never depends on the fetch.
- format-star-count.js: pure compact formatter (1234 -> 1.2k) with tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jason5ng32andClaude Opus 4.8 committed 2026-06-16 11:08:24 +08:00
1 parent 976e971371
commit 83752e2089
7 files changed
+152 -11

No files matched your search

+2
View File
@@ -22,6 +22,8 @@ api/
│ ipcheck-ing.js, maxmind.js ← IP geolocation source handlers (route per source)
├── invisibility-test.js ← /api/invisibility — proxy to private IPCheck.ing endpoint
├── mac-checker.js ← /api/macchecker — MAC vendor lookup
├── github-stars.js ← /api/github-stars — repo star count via no-token
│ GitHub fetch (edge-cached; replaces shields.io)
├── get-whois.js ← /api/whois — whoiser primary + RDAP fallback for new gTLDs
├── cf-radar.js ← /api/cfradar — ASN details via Cloudflare Radar
├── dns-resolver.js ← /api/dnsresolver — DNS + DoH parallel query
+38
View File
@@ -0,0 +1,38 @@
// /api/github-stars — stargazer count for this project's repo.
//
// Fetched from GitHub's public REST API without a token: `stargazers_count` is
// available unauthenticated, which sidesteps shields.io's token-pool outages
// ("Unable to select the next GitHub token from pool"). The route is edge-cached
// for a day (see backend-server.js), so behind Cloudflare the origin queries
// GitHub at most once per cache window — far under the 60 req/hour
// unauthenticated limit — and every other request is served from the CF edge.
import { fetchUpstream } from '../common/fetch-with-timeout.js';
import logger from '../common/logger.js';
// The project's own repository. GitHub requires a User-Agent on every request.
const REPO = 'jason5ng32/MyIP';
export default async (req, res) => {
// Defensive; app.get() in backend-server.js already gates method, but a
// dedicated smoke test asserts this branch directly against the handler.
if (req.method !== 'GET') {
return res.status(405).json({ message: 'Method Not Allowed' });
}
try {
const apiRes = await fetchUpstream(`https://api.github.com/repos/${REPO}`, {
headers: {
'Accept': 'application/vnd.github+json',
'User-Agent': 'MyIP-IPCheck.ing',
},
});
if (!apiRes.ok) {
throw new Error(`GitHub API responded ${apiRes.status}`);
}
const data = await apiRes.json();
res.json({ stars: data.stargazers_count ?? 0 });
} catch (error) {
logger.error({ err: error }, 'github-stars handler failed');
res.status(500).json({ error: 'Failed to fetch GitHub stars' });
}
};
+2
View File
@@ -31,6 +31,7 @@ import { getSessionResult as dnsLeakGetResult } from './api/dns-leak-test.js';
import getWhois from './api/get-whois.js';
import invisibilitytestHandler from './api/invisibility-test.js';
import macChecker from './api/mac-checker.js';
import githubStarsHandler from './api/github-stars.js';
// User
import validateConfigs from './api/configs.js';
import getUserinfo from './api/get-user-info.js';
@@ -210,6 +211,7 @@ app.get('/api/ipapiis', requireValidIP(), cacheable(ONE_HOUR_CACHE), ipapiisHand
app.get('/api/ip2location', requireValidIP(), cacheable(ONE_HOUR_CACHE), ip2locationHandler);
app.get('/api/macchecker', cacheable(THIRTY_DAYS_CACHE), macChecker);
app.get('/api/maxmind', requireValidIP(), cacheable(ONE_DAY_CACHE), maxmindHandler);
app.get('/api/github-stars', cacheable(ONE_DAY_CACHE), githubStarsHandler);
app.get('/api/service-status', cacheable(FIVE_MIN_CACHE), serviceStatusHandler);
app.get('/api/service-status/detail', requireValidProviderId(), cacheable(FIVE_MIN_CACHE), serviceStatusDetailHandler);
+35 -11
View File
@@ -63,21 +63,22 @@
{{ t(`nav.${item}`) }}
</a>
</template>
<!-- GitHub repo link + star count. Count comes from our own backend
(/api/github-stars, edge-cached) instead of shields.io, which kept
failing on token-pool exhaustion. The count is hidden until it
lands / on error, so the link itself never depends on the fetch. -->
<Badge variant="outline" v-if="githubStarsLabel">
<a :href="t('page.footerLink')" target="_blank" rel="noopener"
class="ml-2 inline-flex items-center hover:opacity-80 transition-opacity" aria-label="View source on GitHub">
<img src="https://img.shields.io/github/stars/jason5ng32/MyIP" alt="GitHub stars" class="h-5">
class="inline-flex items-center gap-1"
aria-label="View source on GitHub">
<Icon icon="ri:github-line" class="size-3.5" />
<span class="tabular-nums">{{ githubStarsLabel }}</span>
</a>
</Badge>
</div>
<!-- Right: Action area (ml-auto push to the right) -->
<div class="ml-auto flex items-center gap-2">
<!-- Mobile: GitHub icon -->
<Button v-if="isMobile" variant="ghost" size="icon" class="size-8" as-child>
<a :href="t('page.footerLink')" target="_blank" rel="noopener" aria-label="View source on GitHub">
<Icon icon="ri:github-line" />
</a>
</Button>
<!-- Preferences -->
<JnTooltip :text="t('shortcutKeys.Preferences')">
<Button variant="ghost" size="icon" class="size-8 cursor-pointer" aria-label="Open preferences" @click="OpenPreferences">
@@ -213,9 +214,14 @@
</a>
</template>
<a :href="t('page.footerLink')" target="_blank" rel="noopener"
class="mt-3 inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium hover:bg-muted">
class="mt-3 flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium hover:bg-muted">
<Icon icon="ri:github-line" class="size-4" />
<span>Star on GitHub</span>
<!-- Same /api/github-stars count as the desktop badge (fetched on
mount); hidden until it lands / on error. -->
<span v-if="githubStarsLabel" class="ml-auto tabular-nums text-muted-foreground">
{{ githubStarsLabel }}
</span>
</a>
</nav>
</SheetContent>
@@ -252,12 +258,14 @@ import {
} from '@/components/ui/dropdown-menu';
import {
Award, ChevronDown, HeartHandshake,
LogOut, Menu,Cog
LogOut, Menu, Cog,
} from '@lucide/vue';
import { Icon } from '@iconify/vue';
import brandIcon from './svgicons/Brand.vue';
import { SECTION_IDS } from '@/data/sections';
import { ADVANCED_TOOLS } from '@/data/tools.js';
import { fetchWithTimeout } from '@/utils/fetch-with-timeout.js';
import { formatStarCount } from '@/utils/format-star-count.js';
import { isRunningAsPwa } from '@/utils/pwa.js';
const { t } = useI18n();
@@ -285,6 +293,21 @@ const advancedTools = computed(() =>
// Mobile: Advanced Tools sub-list expanded by default for discoverability.
const mobileToolsOpen = ref(true);
// GitHub star count for the repo badge. Fetched from our own edge-cached
// endpoint; stays null (badge hides the count) if the request fails.
const githubStars = ref(null);
const githubStarsLabel = computed(() => formatStarCount(githubStars.value));
const fetchGithubStars = async () => {
try {
const res = await fetchWithTimeout('/api/github-stars');
if (!res.ok) return;
const data = await res.json();
if (typeof data.stars === 'number') githubStars.value = data.stars;
} catch {
/* leave the badge without a count */
}
};
// nav link style — current section highlight use bg-accent instead of only bold
const navLinkClass = (item, { block = false } = {}) => {
const base = 'rounded-md px-3 py-1.5 text-sm font-medium no-underline cursor-pointer transition-colors';
@@ -418,6 +441,7 @@ onMounted(() => {
lastScrollY = window.scrollY;
window.addEventListener('scroll', onScroll, { passive: true });
}
fetchGithubStars();
});
onBeforeUnmount(() => {
+27
View File
@@ -0,0 +1,27 @@
// Compact formatter for the GitHub star count shown in the nav badge.
// Mirrors the shields.io style we replaced: < 1000 stays exact, larger values
// collapse to a one-decimal "k" / "M" (trailing ".0" dropped). Returns '' for
// anything that isn't a usable non-negative number, so the caller can simply
// hide the count when the fetch fails or hasn't landed yet.
const UNITS = [
{ value: 1e6, suffix: 'M' },
{ value: 1e3, suffix: 'k' },
];
export function formatStarCount(value) {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return '';
if (value < 1000) return String(Math.floor(value));
for (const { value: unit, suffix } of UNITS) {
if (value >= unit) {
const scaled = value / unit;
// Three-or-more-digit results (e.g. 123k) read better without a decimal.
const str = scaled >= 100
? String(Math.round(scaled))
: scaled.toFixed(1).replace(/\.0$/, '');
return str + suffix;
}
}
return String(Math.floor(value));
}
+12
View File
@@ -19,6 +19,7 @@ import getWhoisHandler from '../api/get-whois.js';
import cfRadarHandler from '../api/cf-radar.js';
import invisibilityHandler from '../api/invisibility-test.js';
import macCheckerHandler from '../api/mac-checker.js';
import githubStarsHandler from '../api/github-stars.js';
import updateAchievementHandler from '../api/update-user-achievement.js';
import ipcheckIngHandler from '../api/ipcheck-ing.js';
import { getSessionResult as dnsLeakGetResult } from '../api/dns-leak-test.js';
@@ -153,6 +154,17 @@ describe('get-whois handler', () => {
});
});
// -- github-stars handler -------------------------------------------------
describe('github-stars handler', () => {
it('rejects non-GET with 405 before hitting GitHub', async () => {
const res = createResponse();
await githubStarsHandler(createRequest({ method: 'POST' }), res);
assert.equal(res.statusCode, 405);
assert.deepEqual(res.body, { message: 'Method Not Allowed' });
});
});
// -- cf-radar handler -----------------------------------------------------
describe('cf-radar handler', () => {
+36
View File
@@ -0,0 +1,36 @@
// Unit tests for the GitHub star-count compact formatter.
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { formatStarCount } from '../frontend/utils/format-star-count.js';
describe('formatStarCount', () => {
it('returns exact integers below 1000', () => {
assert.equal(formatStarCount(0), '0');
assert.equal(formatStarCount(7), '7');
assert.equal(formatStarCount(999), '999');
});
it('collapses thousands to one-decimal k, dropping trailing .0', () => {
assert.equal(formatStarCount(1000), '1k');
assert.equal(formatStarCount(1234), '1.2k');
assert.equal(formatStarCount(2000), '2k');
assert.equal(formatStarCount(12345), '12.3k');
});
it('drops the decimal once the k value reaches three digits', () => {
assert.equal(formatStarCount(123456), '123k');
});
it('collapses millions to M', () => {
assert.equal(formatStarCount(1234567), '1.2M');
});
it('returns empty string for unusable input', () => {
assert.equal(formatStarCount(null), '');
assert.equal(formatStarCount(undefined), '');
assert.equal(formatStarCount(NaN), '');
assert.equal(formatStarCount(-5), '');
assert.equal(formatStarCount('1234'), '');
});
});