GitHub profile cards rendered on Cloudflare Workers, with a KV last-known-good cache that keeps the cards up even when the GitHub API fails or rate-limits.
It is a from-scratch rewrite that combines two projects onto one Worker:
- The four profile summary cards from vn7n24fzkq/github-profile-summary-cards —
profile-details,stats,repos-per-language,most-commit-language. - The compact top languages card from anuraghazra/github-readme-stats —
top-langs.
No Vercel, no serverless functions on Node — one Worker, SVG built as strings (no jsdom), data cached in KV, responses cached at the edge.
The public instances of these projects go down or rate-limit, and the card in your README turns into a broken image. This version caches each user's data in Workers KV. If GitHub is unavailable on a refresh, the Worker serves the last good data instead of failing. A card only errors when the data is both missing from KV and GitHub is down.
| Path | Card |
|---|---|
/api/cards/profile-details |
name, total contributions, public repos, join date, email/company/location, and a last-year contribution area chart |
/api/cards/stats |
total stars, commits, PRs, issues, contributed-to |
/api/cards/repos-per-language |
donut of repositories grouped by primary language |
/api/cards/most-commit-language |
donut of languages by commit count |
/api/top-langs |
"Most Used Languages" — compact / normal / pie / donut / donut-vertical |
<img
width="600"
src="https://github-cards-cf.hunterchen.workers.dev/api/cards/profile-details?username=hunterchen7&theme=github_dark"
/>
<img
src="https://github-cards-cf.hunterchen.workers.dev/api/top-langs?username=hunterchen7&layout=compact&hide_border=false&bg_color=0D1116&border_color=2E353A&title_color=0267D6&text_color=0267D6&hide=jupyter%20notebook,TeX,css,scss,HTML,javascript,html"
/>| Param | Applies to | Default | Notes |
|---|---|---|---|
username |
all | — | required |
theme |
all | default |
named theme, e.g. github_dark |
title_color, text_color, bg_color, border_color, icon_color, chart_color |
all | theme | bare or #-prefixed hex overrides |
name |
profile-details | — | override the displayed name |
hide_logo |
stats | false |
hide the large GitHub logo |
exclude |
language cards | — | comma list of languages to hide (aliases like js, ts are resolved) |
exclude_repos |
language cards | — | comma list of repo names / owner/repo to skip |
include_private |
language cards | INCLUDE_PRIVATE env |
count private repos in the language stats — see Private repositories |
animation |
all | — | entrance animation: fade, rise, draw, stagger, load, sequence, tint, rgb, rgb-soft (pure CSS, camo-safe; respects prefers-reduced-motion) |
duration |
all | preset default | animation speed in seconds (0.2–10) |
| Param | Default | Notes |
|---|---|---|
username |
— | required |
layout |
normal |
compact, normal, pie, donut, or donut-vertical |
hide |
— | comma list of languages to hide |
langs_count |
6 (compact) / 5 (normal) | 1–20 |
card_width |
300 | min 280 |
title_color, text_color, bg_color, border_color |
theme | bare hex (no # needed) |
hide_title, hide_border, hide_progress, disable_animations |
false |
booleans |
exclude_repo |
— | comma list of repo names to skip |
include_private |
INCLUDE_PRIVATE env |
count private repos — see Private repositories |
custom_title |
"Most Used Languages" | override the title |
stats_format |
percentages |
or bytes |
Note:
show_icons/icon_colorhave no effect on top-langs (they are ignored upstream too).
The language cards (top-langs, repos-per-language, most-commit-language) can count your private repos, not just public ones. Two things control this:
- The token must be able to see private repos:
- A fine-grained token with Repository access → All repositories and Repository permissions → Metadata: Read (plus Contents: Read for languages) exposes your private repos to the language queries. ✅
- A classic token with the
reposcope also works. ✅ - A fine-grained token missing those repo permissions, or a no-scope classic token, sees public repos only.
- The
include_privatetoggle decides whether to count them once the token can see them:- Default is the
INCLUDE_PRIVATEenv var (wrangler.toml); override per request with?include_private=false/=true. include_private=falserenders public-only language stats.
- Default is the
Caching gotcha: because the per-user data blob is cached for
CACHE_FRESH_SECONDS(1h), changing the token's permissions won't take effect until the blob refreshes. Force it withwrangler kv key delete "data:v1:repos:<user>" --namespace-id <id>(and:profile:/:commitLangs:).
The contribution count (profile-details "X Contributions" + the graph) is separate: it comes from GitHub's contribution calendar, whose private inclusion is governed by Settings → Public profile → "Include private contributions on my profile" and is reliably included by a classic token. A fine-grained token can under-report private contributions there even though it lists private repos fine.
There's no single read-only token that gives both private languages and the full private contribution count: private languages need a fine-grained (read-only) or classic repo token, while the contribution count needs a classic token. To get both without granting repo write access, use two tokens:
| Secret | Token | Used for | Access |
|---|---|---|---|
GITHUB_TOKEN |
fine-grained (All repositories + Metadata/Contents read) | languages / repos | read-only |
GITHUB_CONTRIB_TOKEN |
classic, no scopes | contribution count + stats | read-only |
Because your "Include private contributions on my profile" setting is on, a classic no-scope token returns your full private-inclusive contribution count — so both tokens stay read-only, and no repo write scope is needed.
npx wrangler secret put GITHUB_TOKEN # fine-grained (languages)
npx wrangler secret put GITHUB_CONTRIB_TOKEN # classic, no scopes (contributions)
If GITHUB_CONTRIB_TOKEN is unset, everything uses GITHUB_TOKEN (single-token mode).
How it works internally: every fetch pulls all repos the token can see, each tagged with an isPrivate flag, and caches that superset in KV. The public/private filter is applied at render time, so toggling include_private never triggers a re-fetch — it recomputes from the cached data (see Architecture).
Privacy note: private repo names + language sizes are cached in your Workers KV (server-side, never returned by any endpoint). The rendered card only ever shows aggregate language percentages — no repo names. Still, be aware that a public profile card built with include_private=true reveals the proportions of languages you use in private work.
The profile-details "Public Repos" count and the stats "Total Stars" remain public-only regardless of this toggle (their labels say "Public").
request ──► edge Cache API (keyed on full URL, incl. theme/params)
│ miss
▼
dataset cache (Workers KV, keyed per username)
profile → profile-details + stats
repos → repos-per-language + top-langs
commitLangs → most-commit-language
│ fresh → serve from KV, GitHub untouched
│ stale → serve stale NOW + refresh after the response ◄── never blocks
│ miss → refetch GitHub GraphQL (the only blocking case)
│ failure → keep serving stale KV ◄── resilience
▼
render SVG (string templates; d3-shape/scale/time-format for the chart)
cron (*/30 * * * *) ──► refresh PREWARM_USERNAMES' datasets ahead of expiry
- Cache key is per username, not per theme/params. Themes and filters only affect rendering, so one cached blob serves every theme and option.
- Freshness vs retention. A blob is "fresh" for
CACHE_FRESH_SECONDS(default 1h): any access within the window is served straight from KV and GitHub is not touched. The blob is retained in KV for 30 days, so last-known-good survives a long outage. - Stale-while-revalidate — why a request must never wait on GitHub. A full refetch takes ~5–10s, which is longer than GitHub's image proxy (camo) will wait, so a card that blocks on one renders blank. So an expired blob is served immediately and the refresh runs after the response via
ctx.waitUntil; the next request gets the fresh data. Only a genuine cold miss (nothing cached at all) blocks. - Single-flight refreshes. A burst of requests against one stale key shares a single background refetch rather than each firing its own (per-isolate; see
inFlightinsrc/cache/kv.ts). - Stale renders are cached briefly. A response served from stale data uses a 60s
max-age/s-maxageinstead of the full TTL, so the refreshed data surfaces on the next request rather than being pinned at the edge for an hour. - Cron pre-warm. Every 30 min the scheduled handler refreshes the datasets for
PREWARM_USERNAMES, comfortably inside the 1h fresh window — so the cards you actually embed are effectively always afresh-kvhit and never even reach the stale path. - No Durable Objects. A read-heavy cached card needs no coordination or strong consistency; KV + the edge Cache API are the right fit.
- Observability headers on every card response:
X-Cache-Source—fresh-kv(served from KV, no GitHub),network(just fetched from GitHub — cold miss),stale-revalidating(stale served instantly, refreshing in the background), orstale-kv(GitHub failed, served last-known-good).X-Data-Age— seconds since the data was fetched from GitHub (climbs toCACHE_FRESH_SECONDS, then resets on refetch).X-Edge-Cache—HITwhen the Cloudflare edge served the SVG without running the worker, elseMISS.
You need a Cloudflare account and Node.js 20+.
-
Install the dependencies.
npm install -
Create the KV namespace.
npx wrangler kv namespace create CARDS_KV -
Copy the returned
idintowrangler.toml, at[[kv_namespaces]]→id. -
Add a GitHub token as a secret. GitHub's GraphQL API requires authentication even for public data, so any valid classic PAT works — no scopes needed for public stats. For private repos in the language cards, use a fine-grained token (All repositories + Metadata/Contents read) or a classic
repotoken; for the email row addread:user(classic) or Email addresses read (fine-grained). See Private repositories.npx wrangler secret put GITHUB_TOKEN -
Deploy the Worker.
npm run deploy
-
Copy the example variables file.
cp .dev.vars.example .dev.vars -
Put your GitHub token in
.dev.vars. This file is git-ignored. -
Start the local server. Wrangler simulates KV automatically.
npm run dev -
Open a card in a browser, for example:
http://localhost:8787/api/cards/profile-details?username=hunterchen7&theme=github_dark
npm test # vitest: rendering, cache behavior, unit tests
npm run typecheck # tsc --noEmit
The render tests write sample SVGs so you can inspect the output visually.
Set these in wrangler.toml under [vars]:
| Variable | Default | Meaning |
|---|---|---|
CACHE_FRESH_SECONDS |
3600 (1h) |
how long a KV blob is fresh before a refetch is attempted |
BROWSER_CACHE_SECONDS |
3600 (1h) |
max-age sent to the browser / GitHub camo proxy |
EDGE_CACHE_SECONDS |
3600 (1h) |
edge Cache API TTL (s-maxage) |
EXCLUDE_REPO |
— | optional comma list of repos to exclude globally |
INCLUDE_PRIVATE |
false |
default for counting private repos in the language cards (details) |
PREWARM_USERNAMES |
— | comma list of usernames the cron refreshes ahead of expiry, so your embedded cards are always a fresh-kv hit |
The cron schedule itself lives under [triggers] in wrangler.toml (default */30 * * * *). Keep it shorter than CACHE_FRESH_SECONDS so a pre-warmed blob never has a chance to go stale.
MIT. This project ports code from github-profile-summary-cards (MIT) and github-readme-stats (MIT); see LICENSE.