Jesse A. Green / notebook

Live demo: the DocCompiler guide library as a static, read-only snapshot (October 2026). No backend, no accounts, nothing to submit.

GitHub Readme Stats — Administrator & Developer Guide

Generated April 2026 from anuraghazra/github-readme-stats at 5df91f9

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

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:

  1. Reads its parameters from req.query.
  2. Runs the request through guardAccess (whitelist/blacklist gate).
  3. Validates the locale against the I18n catalog.
  4. Calls a fetcher (src/fetchers/<x>.js) that issues a GraphQL or REST request to GitHub (or WakaTime) through the rotating-PAT retryer.
  5. Passes the typed result into a renderer (src/cards/<x>.js).
  6. Sets Cache-Control headers 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, and word-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.env is 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 renderError SVG 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.env at module load. Redeploy after adding tokens. The retryer treats Bad credentials and Sorry. 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-Control honouring at the proxy level (e.g., Cloudflare Page Rules) to amplify the in-card s-maxage. Cloudflare honours s-maxage for 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:

  1. Add more PATs. Each PAT_N is one tier of headroom. The retryer rotates on RATE_LIMITED GraphQL errors and Bad credentials/ account suspended REST errors.
  2. Increase cache_seconds per request (or with CACHE_SECONDS env). Longer TTLs reduce upstream pressure linearly.
  3. 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.
  4. Whitelist usage. Setting WHITELIST=user1,user2 blocks 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) AND FETCH_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. Reading repoNodesWithStars is 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/up into 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 credentials or RATE_LIMITED.
  • [ ] If user-reported, ask for the exact rendering URL — frequently the cause is a typo (hide_border=1 instead of =true).
  • [ ] Force a Camo cache bust by adding &v=N to the README URL.
  • [ ] If WakaTime card is failing, hit https://wakatime.com/api/v1/users/<user>/stats directly 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 WHITELIST is set. The reasoning is: if you've explicitly whitelisted, the blacklist is irrelevant. Don't set WHITELIST to 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_domain to your sanctioned WakaTime/Wakapi host and ignoring the query parameter — patch api/wakatime.js and remove api_domain from the destructure.
  • At minimum, validate api_domain against 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_domain or 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:

  1. <style> block (animations + caller's CSS unless NODE_ENV=test)
  2. Optional <linearGradient> if bg_color is array-shaped
  3. Background <rect> with rounded corners, theme-colored stroke
  4. Title row (unless hideTitle)
  5. Body group (caller-provided)
  6. 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-check at the top of every file enables TypeScript checking in JSDoc.
  • Public surface: every function has a JSDoc block with @param, @returns, often @typedef for shared shapes.
  • ESM only: import / export. package.json has "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:

  1. api_domain SSRF hardening (issue #4859, #4832). Move beyond trailing-slash strip; whitelist the WakaTime/Wakapi hosts.
  2. Custom font support (issue #4105). Currently every card hardcodes 'Segoe UI', Ubuntu, Sans-Serif. A font_family= query parameter would unlock big customisation gains.
  3. Multi-token weighted retry. The retryer chooses tokens in numeric order; under sustained load, PAT_1 exhausts before PAT_2 ever activates. A round-robin or remaining-budget-weighted strategy would balance better.
  4. Output-size guardrails. Some bg_color gradient configurations can produce SVGs in the 30+ KB range with many stops; bounds are not enforced.
  5. TypeScript migration. // @ts-check plus JSDoc gives ~80 % of the safety; the remaining 20 % is in the renderers where typedefs leak into string interpolation.
  6. First-class fine-grained PAT support. The retryer's Bad credentials heuristic doesn't differentiate "expired classic PAT" from "fine-grained PAT lacking scope". Rich error surfacing in pat-info would 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: ![alt](URL)
  • HTML <img> tags (when you need attributes like height= and align=)
  • 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.