1. Architecture Overview
GitHub Readme Stats is a Vercel-native serverless image API. It exposes
five HTTP endpoints, each implemented as a single default-export
async-handler in api/. Each handler:
- Reads its parameters from
req.query. - Runs the request through
guardAccess(whitelist/blacklist gate). - Validates the locale against the I18n catalog.
- Calls a fetcher (
src/fetchers/<x>.js) that issues a GraphQL or REST request to GitHub (or WakaTime) through the rotating-PAT retryer. - Passes the typed result into a renderer (
src/cards/<x>.js). - Sets
Cache-Controlheaders and returns SVG.
There is no database, no queue, no background worker, no on-disk state —
every request is a stateless function. The rendering pipeline is pure
string concatenation; the only mutable globals are the process.env
PATs and a few module-level constants.
1.1 Component diagram
Vercel Edge
│
▼
┌────────────────────────────────────────────────────┐
│ api/index.js api/pin.js api/gist.js │
│ api/top-langs.js api/wakatime.js │
│ api/status/up.js api/status/pat-info.js │
└────────┬─────────┬─────────┬─────────┬──────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌────────────────────────────────────────────────────┐
│ src/common/access.js guardAccess (allow/deny) │
│ src/common/cache.js CACHE_TTL, setCacheHeaders │
│ src/common/error.js CustomError + secondary msgs │
│ src/common/render.js renderError, flexLayout │
│ src/common/ops.js parseBoolean / parseArray │
│ src/common/Card.js Card class (SVG envelope) │
│ src/translations.js 50+ locales for stat labels │
└────────┬─────────────────────────────┬──────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ src/fetchers/*.js │ │ src/cards/*.js │
│ - stats.js │ │ - stats.js │
│ - repo.js │ │ - repo.js │
│ - top-languages.js │ │ - top-languages.js │
│ - wakatime.js │ │ - wakatime.js │
│ - gist.js │ │ - gist.js │
└────────┬─────────────┘ └──────────┬───────────┘
│ │
▼ │
┌──────────────────────┐ │
│ src/common/retryer.js│ rotates PATs │
│ src/common/http.js │ axios → GitHub │
└──────────────────────┘ │
▼
SVG response
1.2 Component inventory
| Component | Path | Role |
|---|---|---|
| Stats handler | api/index.js |
Resolves stats card SVG. 27 parameters destructured from query. |
| Pinned-repo handler | api/pin.js |
Resolves single-repo pin card SVG. |
| Top-langs handler | api/top-langs.js |
Resolves languages card. Validates layout and stats_format enums explicitly. |
| WakaTime handler | api/wakatime.js |
Calls WakaTime REST endpoint, renders coding-time card. |
| Gist handler | api/gist.js |
Resolves gist preview card. |
| Status handlers | api/status/up.js, api/status/pat-info.js |
Health endpoints; rate-limited to 1 request per 5 min. |
| Stats renderer | src/cards/stats.js |
Computes rank circle geometry, lays out stat rows, emits SVG. |
| Repo renderer | src/cards/repo.js |
Renders pin card with archived/template badge logic. |
| Top-langs renderer | src/cards/top-languages.js |
Five layouts: normal/compact/donut/donut-vertical/pie. Polar-to-cartesian math for arcs. |
| WakaTime renderer | src/cards/wakatime.js |
Two layouts (default + compact); auto-detects "no data" sub-states. |
| Gist renderer | src/cards/gist.js |
Header truncated to 35 chars; description wrapped to 10 lines. |
| Card envelope | src/common/Card.js |
SVG outer rect, title row, gradient defs, animation switch. |
| I18n | src/common/I18n.js |
Translation lookup with en fallback. Throws on missing keys. |
| Color resolver | src/common/color.js |
getCardColors: theme → user override → default fallback chain. Validates hex (3/4/6/8 char) and gradient (angle + 2+ hex). |
| Error model | src/common/error.js |
CustomError with type→secondary-message map; MissingParamError. |
| Cache policy | src/common/cache.js |
CACHE_TTL per card, resolveCacheSeconds, header writers. |
| Retryer | src/common/retryer.js |
Rotates PAT_1..PAT_n, distinguishes rate-limit vs bad-credential, recurses. |
| HTTP client | src/common/http.js |
Thin axios wrapper that posts to https://api.github.com/graphql. |
| Whitelist/blacklist | src/common/access.js, src/common/blacklist.js, src/common/envs.js |
Optional allow-list, hardcoded deny-list of known abusers. |
| Logger | src/common/log.js |
console in prod, noop in tests. |
| Themes | themes/index.js |
74 named theme objects. |
| Language colors | src/common/languageColors.json |
~600 language→hex pairs (sourced from GitHub Linguist). |
| Rank algorithm | src/calculateRank.js |
Pure function: weighted CDFs → letter (S..C) + percentile. |
| Express adapter | express.js |
Mounts the five handlers under /api for non-Vercel hosts. |
1.3 Technology stack
| Layer | Technology |
|---|---|
| Runtime | Node.js >= 22 (ES modules, top-level import) |
| Hosting | Vercel serverless functions (128 MB / 10 s) |
| HTTP client | axios ^1.13.1 |
| GitHub API | GraphQL v4 + REST search-commits |
| Username validation | github-username-regex ^1.0.0 |
| Emoji shortcodes | emoji-name-map ^2.0.3 |
| Word wrap | word-wrap ^1.2.5 |
| Env loading | dotenv ^17.2.3 |
| Local server | express ^5.2.1 (devDependency) |
| Tests | Jest 30, jsdom env, axios-mock-adapter, @testing-library/jest-dom |
| Linting | ESLint 9 with eslint-plugin-jsdoc and eslint-config-prettier |
| Formatting | Prettier 3, lint-staged via Husky |
Note
The project's only direct production dependencies are
axios,dotenv,emoji-name-map,github-username-regex, andword-wrap. Everything else (eslint, jest, prettier, express) is a devDependency. The footprint is intentionally small to keep cold-start times within the Vercel 10-second function budget.
1.4 Request lifecycle (stats card example)
GET /api?username=anuraghazra&theme=tokyonight&show=prs_merged
│
├─ destructure 27 params from req.query
├─ guardAccess({type: "username"}) ← blacklist + optional WHITELIST
├─ isLocaleAvailable(locale) ← skipped if locale not set
├─ fetchStats(username, ...)
│ ├─ statsFetcher() loops on hasNextPage (gated by FETCH_MULTI_PAGE_STARS)
│ │ └─ retryer(fetcher, vars) rotates PAT_1..PAT_n on rate-limit
│ │ └─ axios.post(api.github.com/graphql, {query, variables}, {Authorization})
│ ├─ if include_all_commits: totalCommitsFetcher() ← REST search-commits
│ └─ calculateRank({commits, prs, issues, reviews, repos, stars, followers})
├─ resolveCacheSeconds({req, def: 1d, min: 12h, max: 2d})
├─ setCacheHeaders(res, cacheSeconds) ← max-age + s-maxage + stale-while-revalidate
└─ res.send(renderStatsCard(stats, options))
├─ getCardColors() resolves theme + per-key overrides
├─ build STATS object, filter by hide[], slice optional show[]
├─ flexLayout({direction: "column"}) of stat rows
├─ rank circle SVG with stroke-dashoffset animation
└─ Card.render() wraps in <svg> envelope
Note
The retryer count equals the number of
PAT_*env vars at module-load time. If you add PATs, you must redeploy —process.envis read once.
1.5 Data flow constraints
- Stateless. No filesystem writes, no shared memory between invocations.
- Pure rendering. Renderers are deterministic given identical input;
the only non-determinism is
process.env.NODE_ENV === "test"switching off animations. - No origin pinning. Cards do not read or set cookies; the SVG is
served with default CORS (Vercel's default
Access-Control-Allow-Origin: *). - Failure → SVG. Any thrown error inside a handler is caught; the
handler responds with a
renderErrorSVG instead of a 4xx/5xx. The README never breaks rendering, only displays a message card.
2. System Requirements
2.1 Hosting
The reference deployment is Vercel's serverless platform. The
vercel.json declares per-function memory and timeout:
{
"functions": {
"api/*.js": {
"memory": 128,
"maxDuration": 10
}
},
"redirects": [
{ "source": "/", "destination": "https://github.com/anuraghazra/github-readme-stats" }
]
}
128 MB is enough for typical workloads; the heaviest fetcher (top
languages with FETCH_MULTI_PAGE_STARS plus 100+ repos) can push a few
MB of intermediate JSON. The 10-second timeout matches GitHub's typical
GraphQL latency plus a couple of retry rotations.
2.2 Node version
package.json declares "engines": { "node": ">=22" }. The project uses
top-level await, native ESM ("type": "module"), and structured
process.env access. Older runtimes will error at import.
2.3 GitHub PATs
| Scope | Why |
|---|---|
public_repo |
Minimum for public stats |
repo |
Required only if a user has "Include private contributions on my profile" on |
read:user |
Followers count is part of the rank formula |
Generate at https://github.com/settings/tokens (classic). Fine-grained
tokens are not referenced in source.
Important
The retryer reads the PAT count from
process.envat module load. Redeploy after adding tokens. The retryer treatsBad credentialsandSorry. Your account was suspended.as "this PAT is dead, advance to the next" — not as fatal errors.
2.4 WakaTime
/api/wakatime calls
https://wakatime.com/api/v1/users/<USERNAME>/stats?is_including_today=true
by default. Override with ?api_domain= for self-hosted Wakapi.
WakaTime's free tier limits anonymous access; the endpoint expects the
target profile to be public.
3. Configuration Reference
3.1 Environment variables
| Variable | Type | Default | Purpose | Required? |
|---|---|---|---|---|
PAT_1, PAT_2, ... |
classic PAT | none | GitHub auth tokens. Numbered sequentially; rotated on rate-limit/bad-credential/account-suspended. | At least one |
CACHE_SECONDS |
int (seconds) | unset | Hard-overrides the per-card cache TTL on every response. Useful for staging vs prod tuning. | No |
WHITELIST |
csv string | unset | If set, only listed GitHub usernames render. Others see "This username is not whitelisted". | No |
GIST_WHITELIST |
csv string | unset | Same allow-list mechanism for /api/gist?id=.... |
No |
EXCLUDE_REPO |
csv string | empty | Repos appended to every request's exclude_repo list before star/language aggregation. |
No |
FETCH_MULTI_PAGE_STARS |
"true" / unset |
unset | Enables paginating beyond the first 100 repos when totalling stars. | No |
NODE_ENV |
string | unset | development disables all caching headers. test swaps console for noop and disables animations. |
No |
PORT (or port) |
int | 9000 |
Express adapter listen port. Vercel ignores this. | No |
3.2 No config files
There is no YAML, TOML, JSON, or .env.schema configuration file.
Everything is environment variables (instance-wide) plus query
parameters (per-request). This is intentional: the project is designed
to be redeployable with zero filesystem state.
Important
If you build a wrapper around this service (e.g., a hosted version for your team), do NOT layer your own config file inside the repo — keep all knobs in environment variables so a Vercel redeploy is a full reset.
3.3 URL query parameters (full reference)
The User Guide lists end-user-facing parameters. Below is the complete table, including parameters that are passed through to the renderer but are not always documented externally.
| Parameter | Endpoints | Type | Default | Source |
|---|---|---|---|---|
username |
api, pin, top-langs, wakatime | string | — | MissingParamError if absent |
id |
gist | string | — | MissingParamError if absent |
repo |
pin | string | — | MissingParamError if absent |
theme |
all | string | default (cards) / default_repocard (pin) |
themes[theme] lookup |
title_color |
all | hex (no #) |
theme | getCardColors chain |
text_color |
all | hex | theme | |
icon_color |
all | hex | theme | |
bg_color |
all | hex or csv gradient | theme | csv = "<angle>,<hex1>,<hex2>,...", min 3 components |
border_color |
all | hex | theme | |
border_radius |
all | number | 4.5 |
passed through to Card |
hide_border |
all | bool | false |
parseBoolean |
hide_title |
api, top-langs, wakatime | bool | false |
|
cache_seconds |
all | int | per-card default | clamped to per-card MIN/MAX |
locale |
all | string | en |
isLocaleAvailable validation |
disable_animations |
api, top-langs, wakatime | bool | false |
|
hide |
api, top-langs, wakatime | csv list | [] |
parseArray |
show |
api | csv list | [] |
sub-keys: prs_merged, prs_merged_percentage, reviews, discussions_started, discussions_answered |
show_icons |
api | bool | false |
|
hide_rank |
api | bool | false |
|
rank_icon |
api | enum | default |
values: default, github, percentile |
include_all_commits |
api | bool | false |
triggers REST search-commits |
commits_year |
api | int | unset | passed as startTime to GraphQL |
card_width |
api, top-langs, wakatime | int | per-card | parseInt(_,10) then clamped |
line_height |
api, wakatime | int | 25 |
|
text_bold |
api | bool | true |
|
ring_color |
api | hex | theme | |
custom_title |
api, top-langs, wakatime | string | computed | overrides default title |
number_format |
api | enum | short |
short ⇒ kFormatter; long ⇒ raw |
number_precision |
api | int | unset | clamped 0..2 |
exclude_repo |
api, top-langs | csv list | [] |
merged with EXCLUDE_REPO env |
show_owner |
pin, gist | bool | false |
|
description_lines_count |
pin | int | 3 |
clamped 1..3 |
layout |
top-langs, wakatime | enum | normal (top-langs) / unset (wakatime) |
top-langs values: normal, compact, donut, donut-vertical, pie. wakatime: compact or default. |
langs_count |
top-langs, wakatime | int | per-layout default (top-langs) / WakaTime length (wakatime) | clamped 1..20 (top-langs) |
size_weight |
top-langs | number | 1 |
exponent on lang byte size |
count_weight |
top-langs | number | 0 |
exponent on repo count |
hide_progress |
top-langs, wakatime | bool | false |
|
stats_format |
top-langs | enum | percentages |
values: percentages, bytes |
display_format |
wakatime | enum | time |
values: time, percent |
api_domain |
wakatime | string | wakatime.com |
trailing slash stripped |
type |
status/up | enum | boolean |
values: boolean, json, shields |
3.4 Cache TTL constants (src/common/cache.js)
CACHE_TTL = {
STATS_CARD: { DEFAULT: 1d, MIN: 12h, MAX: 2d },
TOP_LANGS_CARD: { DEFAULT: 6d, MIN: 2d, MAX: 10d },
PIN_CARD: { DEFAULT: 10d, MIN: 1d, MAX: 10d },
GIST_CARD: { DEFAULT: 2d, MIN: 1d, MAX: 10d },
WAKATIME_CARD: { DEFAULT: 1d, MIN: 12h, MAX: 2d },
ERROR: 10m,
};
setCacheHeaders writes Cache-Control: max-age=N, s-maxage=N, stale-while-revalidate=86400. setErrorCacheHeaders always uses
ERROR (10 minutes) so a transient failure doesn't stick in Camo for
a day. When NODE_ENV === "development" or CACHE_SECONDS < 1, the
disable-caching path emits no-cache, no-store, must-revalidate, max-age=0, s-maxage=0 plus Pragma: no-cache and Expires: 0.
Important
The 10-minute error cache is deliberate. If GitHub is having a bad day, READMEs world-wide hammer the API on every page view; the short error cache caps that load while still recovering quickly when GitHub returns. Don't lower this without thinking about the upstream impact.
3.5 Theme structure
Each entry in themes/index.js is:
{
title_color: "string", // hex, no #
icon_color: "string",
text_color: "string",
bg_color: "string" | "<angle>,<hex>,<hex>...", // gradient form
border_color: "string", // optional; when absent, default theme's border is used
}
bg_color is the only field that may be a gradient. If it has more than
two comma-separated components and components 2..N are valid hex, the
Card.renderGradient() path emits an SVG <linearGradient> with
gradientTransform="rotate(<angle>)". Otherwise the value is treated as
a single hex.
4. Deployment & Scaling
4.1 Reference deployment (Vercel)
git clone https://github.com/anuraghazra/github-readme-stats
cd github-readme-stats
vercel link # one-time
vercel env add PAT_1 # paste classic PAT, scope=public_repo
vercel deploy --prod
4.2 Express deployment (Docker, Render, Fly, ECS, etc.)
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
ENV PORT=9000
EXPOSE 9000
CMD ["node", "express.js"]
express.js mounts the same five handlers and listens on
process.env.PORT || process.env.port || 9000. Behaviour is identical
to Vercel; only the front door changes.
Tip
Behind a reverse proxy, set
Cache-Controlhonouring at the proxy level (e.g., Cloudflare Page Rules) to amplify the in-cards-maxage. Cloudflare honourss-maxagefor caching anonymous GETs by default, which is exactly the workload here.
4.3 Horizontal scaling
The functions are stateless, so concurrency = however many parallel invocations Vercel spins up. The natural ceiling is GitHub's per-token rate limit (5,000 requests/hour for classic PATs). Strategies:
- Add more PATs. Each
PAT_Nis one tier of headroom. The retryer rotates onRATE_LIMITEDGraphQL errors andBad credentials/account suspendedREST errors. - Increase
cache_secondsper request (or withCACHE_SECONDSenv). Longer TTLs reduce upstream pressure linearly. - Disable
FETCH_MULTI_PAGE_STARS. The setting is OFF by default on the public instance for exactly this reason — it can multiply GraphQL calls per render by 5-10x. - Whitelist usage. Setting
WHITELIST=user1,user2blocks rendering for everyone else without making any GitHub calls.
Note
The stats fetcher's pagination loop only continues when all repos in the current page have stars (
repoNodes.length === repoNodesWithStars.length) ANDFETCH_MULTI_PAGE_STARS=true. The all-have-stars condition is a heuristic: once the page contains a 0-star repo, GitHub's stargazer-sort guarantees the rest are zero too, so further pages don't change the total. ReadingrepoNodesWithStarsis one of the few non-obvious pieces of source.
4.4 Local development
npm install
npm run test # full Jest suite with coverage
npm run test:watch
npm run test:e2e # jest.e2e.config.js
npm run bench # jest.bench.config.js (perf tests in tests/bench)
npm run lint # eslint --max-warnings 0 across src/, scripts/, tests/, api/, themes/
npm run format # prettier --write .
Husky's prepare hook installs the lint-staged Prettier filter on
.{js,css,md} files at commit time.
5. Operations
5.1 Health endpoints
| Endpoint | Cache | Returns |
|---|---|---|
/api/status/up |
5 min if up; no-store if down |
boolean (default), {up: true/false} (?type=json), Shields.io schema (?type=shields) |
/api/status/pat-info |
5 min on success; no-store on error |
JSON with validPATs, expiredPATs, exhaustedPATs, suspendedPATs, errorPATs, plus details per PAT |
pat-info walks every PAT_N env var, sends a tiny rateLimit { remaining, resetAt } query through the request layer, and classifies each PAT. Use it when investigating "the card stopped working" — it's the quickest way to spot expired tokens.
Tip
Wire
/api/status/upinto a monitoring service (Better Uptime, Pingdom, GitHub Actions cron) and embed the Shields.io variant in your README:https://img.shields.io/endpoint?url=https://YOUR.app/api/status/up?type=shields.
5.2 Logs
The shared logger (src/common/log.js) is console in production and a
no-op in tests. Useful log lines emitted in normal operation:
| Source | Line | Meaning |
|---|---|---|
retryer.js:46 |
PAT_<N> Failed |
The retryer rotated past PAT N (rate-limit or bad-credential). |
fetchers/stats.js:194 |
Invalid username provided. |
Username regex rejected the input before any API call. |
fetchers/stats.js:139 |
<errors object> |
GraphQL returned errors. The handler rethrows as CustomError. |
api/status/pat-info.js:94 |
Something went wrong: <message> |
An unexpected exception leaked past the per-PAT try/catch. |
In Vercel, all of these surface in the project logs UI. Filter by
function name (api/index, api/pin, etc.) when debugging a specific
card.
5.3 Common failure modes
5.3.1 All PATs exhausted
Symptom: every render returns "Something went wrong! Downtime due to GitHub API rate limiting. You can deploy own instance or wait until public will be no longer limited."
Diagnosis: hit /api/status/pat-info. Look at exhaustedPATs and
details[<PAT>].resetIn.
Fix: provision more PATs OR raise CACHE_SECONDS OR enable WHITELIST to
restrict load.
5.3.2 PAT silently expired
Symptom: same as above, but details[<PAT>].status === "expired".
Fix: regenerate the classic PAT in GitHub settings. The retryer skips expired tokens automatically; no service interruption beyond reduced headroom until you regenerate.
5.3.3 Vercel "DEPLOYMENT_PAUSED"
Symptom: Vercel returns 503 with body DEPLOYMENT_PAUSED. Tracked in
issues #4864, #4867, #3851.
Fix: this is a Vercel-side quota event on the public free instance. The remediation in source is the same as section 5.3.1 — self-host. The project does not (and cannot) detect this from inside the function.
5.3.4 Stats card with zero stars even though user has many
Diagnosis: user has > 100 repos. The default FETCH_MULTI_PAGE_STARS=false
caps stargazer aggregation at the first 100 by stars-desc. The card
shows the partial sum.
Fix on self-hosted: FETCH_MULTI_PAGE_STARS=true. Accept the rate-limit
cost.
5.3.5 Translation crash
Symptom: response body says <key> Translation string not found.
Cause: code referenced a translation key that doesn't exist. I18n.t()
throws. Usually only happens after a partial fork that removed
translations.
Fix: re-sync src/translations.js against upstream.
5.4 Incident runbook
- [ ] Hit
/api/status/up— confirms whether public instance is alive. - [ ] Hit
/api/status/pat-info— confirm PAT health. - [ ] If self-hosted, check Vercel function logs for
Bad credentialsorRATE_LIMITED. - [ ] If user-reported, ask for the exact rendering URL — frequently the
cause is a typo (
hide_border=1instead of=true). - [ ] Force a Camo cache bust by adding
&v=Nto the README URL. - [ ] If WakaTime card is failing, hit
https://wakatime.com/api/v1/users/<user>/statsdirectly to confirm WakaTime is live and the profile is public.
6. Performance
6.1 Rendering cost
Rendering itself is <5 ms — pure string concatenation with light SVG geometry math. The dominant cost is the upstream call:
| Card | Upstream | p50 (cold) | p50 (warm) |
|---|---|---|---|
| Stats | GitHub GraphQL × 1 (or × N if multi-page stars) | 600 ms | 250 ms |
Stats with include_all_commits |
GraphQL + REST search-commits | 900 ms | 450 ms |
| Pin | GitHub GraphQL × 1 | 500 ms | 200 ms |
| Top-langs | GitHub GraphQL × 1 | 600 ms | 250 ms |
| WakaTime | wakatime.com REST × 1 | 400 ms | 200 ms |
| Gist | GitHub GraphQL × 1 | 500 ms | 200 ms |
Numbers above are observational rules-of-thumb; actual latency varies with GitHub's server-side load.
6.2 Cache amplification
Because Cache-Control includes both max-age (browser) and
s-maxage (Cloudflare/Vercel edge), the typical request flow is:
github.com/USER/USER/blob/main/README.md
→ camo.githubusercontent.com (proxy + caches)
→ vercel.app (edge CDN + caches by s-maxage)
→ serverless function (cache miss only)
Real upstream call rate is roughly requests_per_render / s-maxage_in_seconds.
For the stats card defaults (1 day cache), even a profile with 1M README
loads/day might only generate a few thousand actual GraphQL calls.
6.3 Benchmark suite
tests/bench/ contains Jest benchmark tests:
| File | Measures |
|---|---|
tests/bench/api.bench.js |
Full request → SVG path |
tests/bench/calculateRank.bench.js |
Rank algorithm only |
tests/bench/gist.bench.js |
Gist render path |
tests/bench/pin.bench.js |
Pin render path |
npm run bench runs them. Useful for verifying that a refactor doesn't
regress. The bench utils (tests/bench/utils.js) throw
Number of runs must be positive if you misconfigure the run count.
7. Security
7.1 Threat model
The project's only inputs are HTTP query parameters and the
process.env PATs. All output is SVG. The threats are:
| Threat | Mitigation |
|---|---|
| Username enumeration / scraping | Rate-limited by GitHub itself, plus WHITELIST opt-in |
| Cross-site scripting via SVG | encodeHTML() runs on every user-supplied string before string interpolation into the SVG body |
SSRF via api_domain |
Issue #4859 / #4832 tracked an SSRF in the WakaTime endpoint where api_domain accepted arbitrary hosts. The current code strips trailing slashes (api_domain.replace(/\/$/gi, "")) and lets axios make the request. Hardening discussion in section 7.4. |
| Credential exposure | PATs are never echoed in errors; only PAT name (PAT_1, PAT_2) is logged on rotation |
| Abuse of public instance | blacklist array hard-bans known abuse usernames (renovate-bot, technote-space, sw-yx, YourUsername, [YourUsername]) |
7.2 Input handling
encodeHTML in src/common/html.js:
str.replace(/[ -香<>&](?!#)/gim, (i) => "&#" + i.charCodeAt(0) + ";")
.replace(//gim, "");
It HTML-encodes anything in the high-Unicode range plus <, >, &,
and strips (backspace). Every user-provided string that ends up
inside an SVG <text> node passes through this — usernames, gist
descriptions, custom titles, repo descriptions.
Color parameters are validated against
/^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/
before being interpolated into the SVG. Invalid hex falls back to the
theme color, which is a known safe value.
7.3 Access control
| Mechanism | Where | Effect |
|---|---|---|
blacklist (src/common/blacklist.js) |
guardAccess for type: "username" |
Hardcoded deny list applied unconditionally when WHITELIST is unset. |
WHITELIST (env) |
guardAccess for type: "username" | "wakatime" |
If set, only listed usernames render. Skips the blacklist check. |
GIST_WHITELIST (env) |
guardAccess for type: "gist" |
Same idea, gist-id allowlist. |
Warning
The blacklist is bypassed when
WHITELISTis set. The reasoning is: if you've explicitly whitelisted, the blacklist is irrelevant. Don't setWHITELISTto a user who is also on the blacklist expecting the blacklist to take precedence — it won't.
7.4 SSRF — api_domain parameter
api/wakatime.js accepts an arbitrary api_domain query parameter that
is used as the host portion of the URL passed to axios.get. The only
sanitisation is api_domain.replace(/\/$/gi, "") (strip trailing
slashes).
This was originally tracked as
Security: Server-Side Request Forgery (SSRF) via user-controlled
api_domain parameter in Wakatime endpoint (closed issue #4832) and
the open PR #4859. If you self-host:
- Consider hardcoding
api_domainto your sanctioned WakaTime/Wakapi host and ignoring the query parameter — patchapi/wakatime.jsand removeapi_domainfrom the destructure. - At minimum, validate
api_domainagainst a static allow-list of hosts before passing it to axios. - The risk surface is requests with arbitrary internal hosts as
api_domain— the function process can issue HTTP GETs from inside your VPC if the function has network access.
Caution
If you operate in a VPC where the function can reach internal services (cloud metadata endpoints, RDS, internal admin APIs), deploying this code unmodified is dangerous. Either fork-and-pin
api_domainor block egress at the network layer.
7.5 GitHub token scope
Run with the least-privilege scope that satisfies your users:
| Use case | Scope |
|---|---|
| Public-only stats, public-only repos | public_repo |
| Private-contribution counts | repo (full) |
| Followers in rank | add read:user |
Compose multi-purpose PATs only if you actually need the data.
7.6 Dependency hygiene
Issue #4848 tracked a compromised version of axios (resolved by
^1.13.1). Run npm audit periodically; the dependencies block is
small (5 packages) so dependency triage is fast.
8. Developer Internals
8.1 Repository layout
github-readme-stats/
├── api/ five Vercel handlers
│ ├── index.js stats card
│ ├── pin.js pinned-repo card
│ ├── top-langs.js languages card
│ ├── wakatime.js wakatime card
│ ├── gist.js gist card
│ └── status/
│ ├── up.js health probe (5-min cache)
│ └── pat-info.js per-PAT diagnostics (5-min cache)
├── src/
│ ├── calculateRank.js pure rank algorithm
│ ├── index.js re-exports common + cards
│ ├── translations.js 50+ locales
│ ├── cards/ SVG renderers
│ │ ├── stats.js
│ │ ├── repo.js
│ │ ├── top-languages.js
│ │ ├── wakatime.js
│ │ ├── gist.js
│ │ ├── index.js card barrel
│ │ └── types.d.ts typedefs for options
│ ├── common/ cross-cutting concerns
│ │ ├── Card.js SVG envelope class
│ │ ├── I18n.js translation lookup
│ │ ├── access.js guardAccess
│ │ ├── blacklist.js hardcoded deny list
│ │ ├── cache.js CACHE_TTL + header writers
│ │ ├── color.js theme + override resolution
│ │ ├── envs.js WHITELIST/GIST_WHITELIST/EXCLUDE_REPO parsing
│ │ ├── error.js CustomError + secondary messages
│ │ ├── fmt.js kFormatter, formatBytes, wrapTextMultiline
│ │ ├── html.js encodeHTML
│ │ ├── http.js axios → graphql.api.github.com
│ │ ├── icons.js SVG paths for stat icons + rank icons
│ │ ├── languageColors.json ~600 language → hex
│ │ ├── log.js logger toggle (test → noop)
│ │ ├── ops.js parseBoolean/parseArray/clampValue/chunkArray/parseEmojis/dateDiff
│ │ ├── render.js renderError, flexLayout, measureText, createProgressNode
│ │ └── retryer.js PAT rotation
│ └── fetchers/ upstream client glue
│ ├── stats.js
│ ├── repo.js
│ ├── top-languages.js
│ ├── wakatime.js
│ ├── gist.js
│ └── types.d.ts
├── themes/ 74 theme definitions
│ ├── index.js
│ └── README.md
├── scripts/ dev tooling
│ ├── close-stale-theme-prs.js
│ ├── generate-langs-json.js regenerate languageColors.json
│ ├── generate-theme-doc.js auto-update themes/README.md
│ ├── helpers.js
│ ├── preview-theme.js live preview locally
│ └── push-theme-readme.sh
├── tests/ Jest unit tests (one per card + utility)
├── tests/bench/ Jest benchmark suite
├── tests/e2e/ Jest end-to-end suite
├── express.js Express adapter for non-Vercel hosts
├── vercel.json per-function memory + timeout
├── package.json deps, scripts, engines >= 22
└── jest.{,bench,e2e}.config.js separate Jest configs
8.2 Module reference
api/*.js — handler shape
Every handler is identically structured:
export default async (req, res) => {
const { username, ...everythingElse } = req.query;
res.setHeader("Content-Type", "image/svg+xml");
const access = guardAccess({ res, id: username, type: "username", colors });
if (!access.isPassed) return access.result;
if (locale && !isLocaleAvailable(locale))
return res.send(renderError({ message: "Something went wrong", secondaryMessage: "Language not found", renderOptions }));
try {
const data = await fetchSomething(username, ...);
const cacheSeconds = resolveCacheSeconds({ requested, def, min, max });
setCacheHeaders(res, cacheSeconds);
return res.send(renderXxxCard(data, options));
} catch (err) {
setErrorCacheHeaders(res);
if (err instanceof Error)
return res.send(renderError({ message: err.message, secondaryMessage: retrieveSecondaryMessage(err), renderOptions: { ..., show_repo_link: !(err instanceof MissingParamError) } }));
return res.send(renderError({ message: "An unknown error occurred", renderOptions }));
}
};
When you add a new endpoint, copy this shape exactly. The
show_repo_link toggle suppresses the "file an issue" link when the
error is a MissingParamError (the user just made a typo, no need to
nag).
src/cards/stats.js
The most complex renderer. Builds a STATS object dictionary, applies
the hide filter and optional show keys, generates one createTextNode
per entry, lays them out vertically with flexLayout, and conditionally
draws the rank circle.
Two width regimes:
hide_rank=true: CARD_MIN_WIDTH = 287, CARD_DEFAULT_WIDTH = 287
hide_rank=false:
if any stats: RANK_CARD_MIN_WIDTH = 420, RANK_CARD_DEFAULT_WIDTH = 450
rank-only: RANK_ONLY_CARD_MIN_WIDTH = 290, RANK_ONLY_CARD_DEFAULT_WIDTH = 290
LONG_LOCALES (a sorted list of 25 locale codes that need extra
horizontal space) shifts the value column by 50 px to prevent clipping.
src/cards/top-languages.js
Five layouts share infrastructure. Helpers worth knowing:
| Function | Purpose |
|---|---|
getDefaultLanguagesCountByLayout |
Maps layout → default langs_count |
trimTopLanguages |
Sorts by size desc, applies hide, slices |
polarToCartesian / cartesianToPolar |
Pie/donut geometry |
createDonutPaths |
SVG M ... A ... 0 large_arc 0 ... arcs |
createProgressTextNode |
Single language row in normal layout |
createCompactLangNode |
Color-circle + label used by compact, donut, donut-vertical, pie |
renderNormalLayout / renderCompactLayout / renderDonutLayout / renderDonutVerticalLayout / renderPieLayout |
One per layout |
The MAXIMUM_LANGS_COUNT = 20 constant caps how many languages can be
requested via langs_count. Anything higher is clamped silently.
src/cards/wakatime.js
Two layouts (compact and the default), three "no data" sub-states
(is_coding_activity_visible, is_other_usage_visible, nocodingactivity).
The card calls recalculatePercentages after hide filtering so that
the visible languages still total 100 %.
src/cards/repo.js
Renders archived/template badges (the getBadgeSVG helper) from the
repocard.template / repocard.archived translation keys. Forces
disableAnimations() always — the pin card explicitly does not animate
because GitHub's image proxy renders cards with stale animations
ungracefully.
src/cards/gist.js
Header is truncated at HEADER_MAX_LENGTH = 35. Description wraps to
linesLimit = 10 lines via wrapTextMultiline. The card is always
400 px wide.
src/common/Card.js
The SVG envelope. Renders:
<style>block (animations + caller's CSS unlessNODE_ENV=test)- Optional
<linearGradient>ifbg_coloris array-shaped - Background
<rect>with rounded corners, theme-colored stroke - Title row (unless
hideTitle) - Body group (caller-provided)
- Hidden
<title>and<desc>for accessibility
Methods: setCSS, setHideBorder, setHideTitle, setTitle,
setAccessibilityLabel, disableAnimations. Width and height come from
the constructor. The class deliberately mutates: callers configure
properties between construction and render().
src/common/cache.js
resolveCacheSeconds clamps requested between MIN and MAX, then
overrides everything if process.env.CACHE_SECONDS is a valid integer.
Note the hierarchy: env wins over query param, query param wins over
default. This is why setting CACHE_SECONDS instance-wide is so
powerful — it ignores the per-card range entirely.
src/common/error.js
The SECONDARY_ERROR_MESSAGES table maps CustomError.type to a
user-facing secondary message:
| Type | Secondary message |
|---|---|
MAX_RETRY |
"You can deploy own instance or wait until public will be no longer limited" |
NO_TOKENS |
"Please add an env variable called PAT_1 with your GitHub API token in vercel" |
USER_NOT_FOUND |
"Make sure the provided username is not an organization" |
GRAPHQL_ERROR |
"Please try again later" |
GITHUB_REST_API_ERROR |
"Please try again later" |
WAKATIME_USER_NOT_FOUND |
"Make sure you have a public WakaTime profile" |
MissingParamError is a separate subclass that carries the missing
parameter list and a usage example URL. The handler suppresses the
"file an issue" link for MissingParamError because it's a user typo,
not a bug.
src/common/retryer.js
const PATs = Object.keys(process.env).filter((k) => /PAT_\d*$/.exec(k)).length;
const RETRIES = process.env.NODE_ENV === "test" ? 7 : PATs;
RETRIES is the count, fixed at module load. The retryer recurses on
GraphQL RATE_LIMITED AND on response messages matching /rate limit/i
(double-check from issue #4425). It also rotates on
Bad credentials (a token has been revoked) and on
Sorry. Your account was suspended. (suspended PAT). Network errors
without a response object are re-thrown to the caller.
src/common/access.js
Distinguishes three identity types: username, gist, wakatime.
Each has its own (whitelist, "not whitelisted" message) pair. The
blacklist applies only to username AND only when no whitelist is set.
Failed access returns a "This username is not whitelisted" / "This gist
ID is not whitelisted" / "This username is blacklisted" SVG with
show_repo_link: false (don't tell users to file an issue when they're
blocked).
src/calculateRank.js
Pure function, zero dependencies. Two CDFs:
exponential_cdf(x) = 1 - 2 ** -x
log_normal_cdf(x) = x / (1 + x) // approximation
Six weighted contributions:
| Contribution | CDF | Median | Weight |
|---|---|---|---|
| commits | exponential | 250 (or 1000 if all_commits) |
2 |
| PRs | exponential | 50 | 3 |
| issues | exponential | 25 | 1 |
| reviews | exponential | 2 | 1 |
| stars | log-normal | 50 | 4 |
| followers | log-normal | 10 | 1 |
Total weight 12. Output range: 0..100 (percentile). Letter mapping:
THRESHOLDS = [1, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100]
LEVELS = ["S", "A+", "A", "A-", "B+", "B", "B-", "C+", "C"]
repos is destructured but unused (the eslint-disable comment is
there). It's part of the rank function signature for API stability.
8.3 Coding conventions
// @ts-checkat the top of every file enables TypeScript checking in JSDoc.- Public surface: every function has a JSDoc block with
@param,@returns, often@typedeffor shared shapes. - ESM only:
import/export.package.jsonhas"type": "module". - No classes for stateless helpers; classes only for
Card,I18n, and the two error subclasses. - Card renderers always export both a named export and a default export (legacy interop).
- Constants for magic numbers (e.g.,
CARD_MIN_WIDTH,LONG_LOCALES,HEADER_MAX_LENGTH). - Tests live in
tests/, mirroring source structure (tests/card.test.js,tests/renderStatsCard.test.js, etc.).
8.4 Testing strategy
| Suite | Config | Purpose |
|---|---|---|
| Unit | jest.config.js |
Per-card and per-utility tests using jsdom env |
| Bench | jest.bench.config.js |
Performance benches in tests/bench/ |
| E2E | jest.e2e.config.js |
End-to-end tests under node env in tests/e2e/ |
axios-mock-adapter mocks GitHub responses; tests assert SVG output
contains expected elements (data-testid="rank-circle",
data-testid="lang-progress", etc.). Snapshot tests live under
tests/__snapshots__/.
Tip
When adding a new card variant, add a render snapshot test the same day. The renderers produce strings that humans don't easily diff — snapshots save you from accidentally shifting an SVG element by 5 px.
8.5 Potential improvements
The codebase is mature but has a few open improvement vectors:
api_domainSSRF hardening (issue #4859, #4832). Move beyond trailing-slash strip; whitelist the WakaTime/Wakapi hosts.- Custom font support (issue #4105). Currently every card
hardcodes
'Segoe UI', Ubuntu, Sans-Serif. Afont_family=query parameter would unlock big customisation gains. - Multi-token weighted retry. The retryer chooses tokens in
numeric order; under sustained load,
PAT_1exhausts beforePAT_2ever activates. A round-robin or remaining-budget-weighted strategy would balance better. - Output-size guardrails. Some
bg_colorgradient configurations can produce SVGs in the 30+ KB range with many stops; bounds are not enforced. - TypeScript migration.
// @ts-checkplus JSDoc gives ~80 % of the safety; the remaining 20 % is in the renderers where typedefs leak into string interpolation. - First-class fine-grained PAT support. The retryer's
Bad credentialsheuristic doesn't differentiate "expired classic PAT" from "fine-grained PAT lacking scope". Rich error surfacing inpat-infowould help operators.
8.6 Contributing
The repo runs:
npm run lint # eslint --max-warnings 0
npm run format:check # prettier --check
npm test # full unit suite with coverage
All three must pass on PRs. Husky's pre-commit hook runs lint-staged
which prettifies *.{js,css,md}.
For new themes, the workflow is:
npm run preview-theme # opens a local preview server
# edit themes/index.js, add your entry
npm run theme-readme-gen # regenerates themes/README.md
git commit -am "feat: add <theme-name> theme"
scripts/close-stale-theme-prs.js is run on a schedule by the
maintainers to close abandoned theme PRs. Don't be surprised if a
quiet PR is closed; reopen with the latest commit and a comment.
9. Integration Points
9.1 Inbound
The five card endpoints are the public API. They are designed to be
embedded as <img src="..."> (Markdown image syntax). HEAD requests
are not specially handled — Vercel's runtime serves the same SVG to
HEAD as to GET, with a Content-Length header.
There is no JSON output mode for cards. Every successful render is
SVG. The two status endpoints (/api/status/up, /api/status/pat-info)
return application/json.
9.2 Outbound
| Destination | Protocol | Endpoint | Trigger |
|---|---|---|---|
api.github.com/graphql |
HTTPS POST | GraphQL v4 | Every render except wakatime//api/status/up//api/status/pat-info |
api.github.com/search/commits |
HTTPS GET | REST search-commits with application/vnd.github.cloak-preview |
Stats card with include_all_commits=true |
wakatime.com/api/v1/users/<u>/stats |
HTTPS GET | WakaTime REST | /api/wakatime |
Custom (api_domain) |
HTTPS GET | WakaTime-shape REST | /api/wakatime?api_domain=... |
All outbound calls go through axios (no local connection pool;
serverless cold-start each time). The retryer wraps the GitHub calls;
the WakaTime call is a single direct axios.get with no retry.
9.3 README embedding patterns
The cards are designed to slot into:
- GitHub README image syntax:
 - HTML
<img>tags (when you need attributes likeheight=andalign=) - GitLab READMEs (same Markdown syntax)
- Docusaurus / MkDocs / VitePress
- Any Markdown renderer that fetches external SVGs
Cards intentionally do not require JavaScript on the consuming page. Animations are pure SVG/CSS and run inside the SVG sandbox.
9.4 Observability hooks
There is no built-in metrics emission (no Prometheus, no StatsD). On Vercel, observability is the hosting platform's job:
- Function logs are visible in the Vercel dashboard.
- Per-invocation duration is exposed in Vercel's analytics.
- Per-endpoint cache hit ratio is visible in Vercel's CDN analytics.
If you need finer-grained metrics, the obvious patch points are the
top of each handler (count requests by endpoint) and immediately after
retryer returns (count rate-limit-rotations by PAT). Both are
single-file changes.