Repository navigation
How It Works
ContribKit never touches the GitHub API. It reads the public contributions page that GitHub renders at github.com/users/{login}/contributions, parses the Contribution Days out of the HTML, builds a calendar grid, and renders it. No token, no OAuth, no private data.
This page follows each thing the web does from the request that starts it to what the visitor sees, failures included. The app's flows are on Mobile App, and the pipeline that ships both is CI/CD.
| Route | Started by | Answers | Grid |
Cache-Control on success |
|---|---|---|---|---|
GET / |
a visit, a shared link with ?user= and ?year=
|
the landing page, server-rendered, then page-init
|
the Year's, buildGridFromApi
|
private, an hour, and only when the visitor asked for someone |
GET /api/contributions |
a search on the landing page, or anyone's code | JSON: the raw Contribution Days and the Total Contributions | none: the client builds it |
public, an hour, revalidated for a day |
GET /user/:username.svg |
an <img>, a README Embed through GitHub's Camo proxy |
an SVG | the Rolling Window, buildRollingGrid
|
public, an hour, revalidated for a day |
POST /api/contact |
the /contact form, or the app's Contact sheet |
202 and an email to the maintainer |
none |
no-store, always |
GET /api/health |
a person checking a deploy | which variables and bindings the Worker has, never their values | none | no-store |
Every request the Worker serves passes through middleware.ts first. Static assets never do: Workers Assets answers them before the Worker runs, so public/_headers carries their headers.
---
config:
look: handDrawn
layout: dagre
---
flowchart TD
request(["request"]) --> agents{"path is /AGENTS?"}
agents -->|"yes"| hidden["404, no-store: the guide is not a page"]
agents -->|"no"| api{"path under /api/?"}
api -->|"no"| route["the route renders"]
api -->|"yes"| which["/api/contact, whatever the method, uses CONTACT_RATE_LIMITER, every other /api/ path API_RATE_LIMITER"]
which --> bound{"binding present?"}
bound -->|"no"| route
bound -->|"yes"| limit{"under the limit for this CF-Connecting-IP?"}
limit -->|"no"| refused["429 Too many requests, Retry-After 60, no-store"]
limit -->|"yes"| route
route --> headers["security headers on the answer, CSP included"]
refused --> headers
hidden --> headers
headers --> embed{"the SVG route?"}
embed -->|"yes"| corp["Cross-Origin-Resource-Policy: cross-origin"]
embed -->|"no"| same["Cross-Origin-Resource-Policy: same-origin"]
-
Only
/api/*is rate-limited, per IP: 100 requests a minute on everything but the contact route, which has its own bucket of 5, because it protects a mailbox rather than an upstream. The SVG route is deliberately not limited: README Embeds reach it through GitHub's shared image proxy, so a per-IP limit would throttle every reader at once (ADR 0010). Its throttle is the edge cache described below. -
A request with no
CF-Connecting-IPshares one bucket, keyedunknown, with every other such request. A missing binding means no limit at all: the middleware lets the request through rather than refusing it. -
The 429 is ContribKit's own, and its body,
{"error":"Too many requests"}, carries nokind: that is how a client tells it from aRateLimitedthat GitHub caused. It is not logged.
A browser asks for /, perhaps with ?user= and ?year= from a shared link, perhaps carrying the ck_user cookie from an earlier search. index.astro renders the whole page on the Worker, calendar included, so the first paint already shows contributions and no script has to run before it does.
---
config:
look: handDrawn
layout: dagre
---
sequenceDiagram
autonumber
actor Visitor
participant Page as index.astro on the Worker
participant Load as loadInitial
participant GitHub as GitHub, behind the edge cache
Visitor->>Page: GET / with user and year, maybe the ck_user cookie
Page->>Page: resolveViewerIdentity picks the Username
Page->>Load: loadInitial with that Username, the year asked for, this year
Load->>Load: an unusable year becomes this year, silently
Load->>Load: parseUsername, and an invalid one fails here, before any request
Load->>GitHub: fetchCalendar for the Username and the Year
GitHub-->>Load: the contributions HTML, parsed into Contribution Days
Load-->>Page: the Year's grid from buildGridFromApi, or a failure with its status
Page->>Page: daySourceFor picks Loaded, Empty or Placeholder
Page->>Page: render the hero and the export preview to SVG strings
Page-->>Visitor: HTML with window.__INITIAL_DAYS__, Cache-Control from cacheControlFor
Whose calendar. resolveViewerIdentity takes, in order: ?user=, trimmed and cut at 40 characters, even when it is not a valid Username, because the visitor asked for it and should be told why it failed; then the ck_user cookie, but only when it parses as a Username, so a stale or tampered cookie is ignored rather than reported; then the first Suggested Username, which counts as not explicit. Which Year. ?year= when it is an integer from 2005 to this year, and this year otherwise, without an error: a bad ?year= in a shared link still shows a calendar. There is no Rolling Window on the landing page, so the year select offers Years only.
What the visitor sees is one of three states, and the cache header follows them:
| Asked for someone? | GitHub answered? | State | Grid | Hero | Cache-Control |
|---|---|---|---|---|---|
| yes | yes | Loaded |
their days for the Year | their Contribution Stats | private, max-age=3600, stale-while-revalidate=86400 |
| yes | no, or the Username was invalid | Empty |
an empty grid for the Year | unknown stats, and the reason under the search box (username not found, check it and try again, invalid username, too many requests, try again in a moment…) | private, no-store |
| no | yes | Loaded |
the Suggested Username's days | its Contribution Stats | private, no-store |
| no | no | Placeholder |
a seeded, invented grid from generateData
|
stats of that grid, no error | private, no-store |
Why private either way. The page can depend on a cookie, so no shared cache may store one visitor's page for another; it is cacheable at all, for an hour in the visitor's own browser, only when the visitor named someone and the calendar loaded. A failure on / is logged like a route's when it is a 5xx (endpoint: page); a 404 or a 429 is not. Anything that throws while rendering is answered by 500.astro.
The page's one module script calls initPage() in page-init.ts. It does not fetch: the server already drew the hero and the export preview, and it handed the same days to the browser as window.__INITIAL_DAYS__, so the client takes those instead of asking again.
-
Validate the injected days.
contributionGridSchemacheckswindow.__INITIAL_DAYS__; anything that does not match, a missing value included, is replaced bygenerateData(), the same seeded placeholder the server falls back to, so the controller never runs on a shape it cannot draw. -
Settle the Username. The search box's server-rendered value becomes the current Username. When the URL named nobody,
seedUsernameCookiewritesck_useronly if no cookie exists yet, so a first visit remembers the Suggested Username without overwriting a returning visitor's. When the URL's?user=is missing or differs from what the server rendered,history.replaceStatewrites the rendered one in, so the address bar always names the calendar on screen. - Wire the page. The Customizer, the Home Screen Widget preview, the export preview, the Palette and Cell Shape radios, the export tabs, the search box, the history listener and the Cell Tooltips.
A search starts from the render button or the form's submit, a change of Year, a Suggested Username, or the browser's back and forward buttons. Each one ends in renderFromGitHub.
---
config:
look: handDrawn
layout: dagre
---
sequenceDiagram
autonumber
actor Visitor
participant Client as page-init in the browser
participant API as GET /api/contributions
Visitor->>Client: render, a Year, a suggestion, or back and forward
Client->>Client: lowercase and trim, then parseUsername
alt empty or invalid
Client-->>Visitor: the reason under the search box, and no request
else a Username
Client->>Client: pushState with user, and year unless it is this year, except on back and forward
Client->>API: fetch user and year, the year always sent, with a 20 second timeout
alt 200 with a calendar body
API-->>Client: days and total
Client->>Client: write ck_user, buildGridFromApi, Contribution Stats
Client-->>Visitor: grid, stats, Customizer and export preview redrawn
else any other status, or a body of the wrong shape
API-->>Client: error, kind and maybe field
Client-->>Visitor: contributionError sentence, an empty grid, unknown stats, the cookie unchanged
else no answer: offline, refused, or past 20 seconds
Client-->>Visitor: could not reach the server, try again
end
end
-
The Year is always sent, so the client never asks the API for the Rolling Window; the URL carries
?year=only when it is not this year, so a shared link stays short. - The cookie is written on success only. A Username that 404s is never remembered for the next visit.
-
Back and forward re-run the search from the URL's
?user=and?year=without pushing a new entry; an invalid?user=there shows its reason and requests nothing. - A search is never cancelled. The render button is disabled while a request is out, but a change of Year or a suggestion can start a second one, and whichever answers last is what the page shows.
- Each outcome records one Usage Event,
calendar_renderedorcalendar_render_failedwith its reason, and only once the visitor has consented (Web Application).
---
config:
look: handDrawn
layout: dagre
---
flowchart TD
request(["GET /user/name.svg, from an img or Camo"]) --> username{"parseUsername"}
username -->|"invalid"| bad["400 text/plain, no-store"]
username -->|"a Username"| fetch["loadContributions for the Rolling Window"]
fetch --> ok{"a calendar?"}
ok -->|"no"| failed["statusFor and messageFor: 404, 429 with GitHub's Retry-After, or 502, all text/plain and no-store, logged when 5xx"]
ok -->|"yes"| options["embedQuerySchema: palette, shape and background, each falling back to its default"]
options --> grid["buildRollingGrid: 53 weeks ending on the Saturday of the latest day"]
grid --> svg["svgStringRenderer: string concatenation, no DOM"]
svg --> answer["200 image/svg+xml, public, max-age=3600, stale-while-revalidate=86400"]
request -. "anything thrown" .-> boom["500 text/plain, no-store, logged"]
[username].svg.ts is the only route that ignores ?year=: an Embed is pasted into a README once, so it always draws the Rolling Window, on the fixed 53-week grid. A junk palette, shape or background degrades to the default instead of breaking the image. Nothing about the visitor changes the answer, so it is public, and the Worker is not rate-limited on it: the edge cache on the GitHub request is what keeps a cache-busting caller from making GitHub throttle the Worker. Every failure is no-store, so a transient one is never replayed into a README through Camo.
---
config:
look: handDrawn
layout: dagre
---
flowchart TD
request(["GET /api/contributions with user and year"]) --> limit{"middleware: under 100 a minute for this IP?"}
limit -->|"no"| refused["429 Too many requests, Retry-After 60, no-store, no kind"]
limit -->|"yes, or no binding"| user{"user present?"}
user -->|"no"| missing["400 InvalidInput, field username"]
user -->|"yes"| username{"parseUsername"}
username -->|"invalid"| badUser["400 InvalidInput, field username"]
username -->|"a Username"| year{"parseYear"}
year -->|"not an integer, or outside 2005 to this year"| badYear["400 InvalidInput, field year"]
year -->|"missing: the Rolling Window"| fetch["loadContributions"]
year -->|"a Year"| fetch
fetch --> ok{"a calendar?"}
ok -->|"no"| failed["status from statusFor, body error and kind, Retry-After on GitHub's 429, logged when 5xx"]
ok -->|"yes"| answer["200: username, the raw days, total"]
user -. "anything thrown in the route" .-> boom["500, error and no kind, no-store, logged"]
contributions.ts returns the Contribution Days as parsed, date, level and count, with no grid: the landing page's client builds its own with buildGridFromApi, and any other caller builds whatever it needs. Every error answer is no-store and carries the failure's kind, so a client can word it without reading error; the two answers with no kind are the middleware's own 429 and the 500 an unexpected throw gets. API Reference has every status.
A Contact Message comes from the /contact page, through contact-form.ts, or from the app's Contact sheet. It leaves as an email and is stored nowhere (ADR 0030).
---
config:
look: handDrawn
layout: dagre
---
sequenceDiagram
autonumber
actor Visitor
participant Form as contact-form.ts
participant MW as middleware
participant Route as api/contact.ts
participant Repo as cloudflareContactMessageRepository
participant Email as Email Routing
Visitor->>Form: send
Form->>Form: check every field, and send nothing while one is invalid
Form->>MW: POST name, email, message and the website honeypot, 20 second timeout
alt over 5 a minute from this IP
MW-->>Form: 429 Too many requests, Retry-After 60
else under the limit
MW->>Route: the request
alt not the expected JSON shape
Route-->>Form: 400 Invalid request body
else the honeypot is filled
Route-->>Form: 202, and nothing is sent
else a field fails parseContactMessage
Route-->>Form: 400 with the field
else valid
Route->>Repo: deliver
alt the CONTACT_EMAIL binding is absent
Repo-->>Route: throws
Route-->>Form: 500, logged
else the binding is there
Repo->>Email: send the MIME message to MAINTAINER_EMAIL
alt Email Routing refuses
Email-->>Repo: rejected
Repo-->>Route: Delivery failure
Route-->>Form: 502 Could not send your message, logged with the reason
else accepted
Route-->>Form: 202 accepted
end
end
end
end
Form-->>Visitor: sent, or the field or sentence the server named
-
The missing binding is the one throw.
cloudflareContactMessageRepositoryreturns aDeliveryfailure when Email Routing refuses a send, but throws whenCONTACT_EMAILis not bound at all, because that is a broken deployment rather than a refused message. The route's own boundary catches it, logs it and answers500 {"error":"Something went wrong. Please try again."},no-store./api/healthreports whether the binding is present. - Nothing about the message is logged. A 502 writes one line with the failure's kind, status and Email Routing's reason, and a 500 the thrown error; never the name, the address or the text.
-
The form tells a refusal from silence. A 2xx clears the form and says the message is on its way; any other answer points at the field the body names, or shows its
error, and records the Usage Event outcomerejected, a 502 included; a request that never answers shows could not send your message, try again in a moment and recordsfailed. -
Every answer is
no-store, the202included.
---
config:
look: handDrawn
layout: dagre
---
flowchart LR
reader(["browser, or GitHub's Camo"]) -->|"Cache-Control on the answer"| worker["the Worker's route"]
worker -->|"fetch with cf.cacheTtl 3600 and cacheEverything"| edge["Cloudflare's cache for the subrequest"]
edge -->|"a miss, at most once an hour per URL in each Cloudflare location"| github["github.com contributions page"]
-
The GitHub request, at the edge.
github-html-contributions-repository.tsfetches withcf: { cacheTtl: 3600, cacheEverything: true }, so Cloudflare keeps GitHub's answer for an hour, keyed on the GitHub URL: the Username and the Year's date range. Every route shares it, and whatever query string the inbound request carried, a Username and Year cost GitHub at most one request an hour in each Cloudflare location that serves them. This is the SVG route's real throttle. It never shows in a response header. Which statuses it holds is Cloudflare's rule rather than this repository's: nothing here setscacheTtlByStatus, so whether a 404 or a 429 GitHub sent is kept for the hour too is not something the code decides. -
The answer, in the reader's caches. A calendar from the SVG or the JSON route is
public, max-age=3600, stale-while-revalidate=86400: an hour fresh, then served stale for up to a day while it revalidates. GitHub's Camo proxy caches README images on top of that. Every failure isno-store, as is every answer of the contact and health routes, so a transient error is never stored and replayed. -
The landing page, in the visitor's browser alone.
private: an hour, revalidated for a day, when the visitor named someone and the calendar loaded, andno-storeotherwise (above).
-
Validation parses query and route params with Zod, then constructs domain value objects:
-
parseUsernametrims input and tests it against a deliberately looser approximation of GitHub's rules: alphanumeric, hyphens allowed inside, 1–39 chars. It does not reject consecutive hyphens, soa--bpasses here and 404s at GitHub. If aUsernameexists, it is valid. -
parseYearacceptsnull/empty (→ the Rolling Window), rejects non-integers, and bounds the year to2005 …the year the route read off the clock. - Any invalid input becomes a typed
Failurebefore any network call.
-
-
loadContributions({ username, year })reaches the repository. It is a one-line arrow overgithubHtmlContributionRepository.fetchCalendar, bound once at the module scope ofpages/_contributions.ts, so warm Worker isolates reuse it. There is no use case between the route and the repository, because the one that used to sit there wasrepository => params => repository.fetchCalendar(params)and asserted only that JavaScript forwards arguments. - Fetching requests the public contributions HTML from GitHub with browser-like headers and a 20 second timeout. See Fetching Contributions.
-
Parsing extracts each day's
date,level(0–4, run throughclampLevelas the Contribution Day is built), and exactcount(from the linked<tool-tip>) via regex over the HTML. See HTML Parsing. -
Grid building happens where a grid is drawn:
buildGridFromApilays a Year over its 53 or 54 Sunday-aligned weeks for the landing page, andbuildRollingGridlays the Rolling Window over a fixed 53×7 (371-cell) grid for the SVG route. The JSON route builds none. See Calendar Grid. - Rendering turns a grid into an SVG string using the selected palette, shape, and background: on the Worker for the SVG route and the landing page's first paint, and in the browser after a search. See SVG Rendering.
Every function on a request's path that can fail returns T | Failure rather than throwing. A Failure is a typed discriminated union created by small constructors (notFound, invalidInput, network, upstream, parse, rateLimited, delivery) and narrowed with the isFailure guard:
type Failure =
| { kind: "NotFound"; username: Username }
| { kind: "InvalidInput"; field: "username" | "year" | "color" | "date" | "name" | "email" | "message"; message: string }
| { kind: "Network"; message: string }
| { kind: "Upstream"; message: string }
| { kind: "Parse"; message: string }
| { kind: "RateLimited"; message: string; retryAfterSeconds: number | null }
| { kind: "Delivery"; message: string };username is the Username value object, not a string, and every field is readonly in the source, domain/failures/failure.ts.
The exception is a deployment fault, not a request's: the contact repository throws when the CONTACT_EMAIL binding is absent. Each route also wraps its handler in a boundary that catches anything thrown, logs it and answers a no-store 500 in the route's own content type, and the landing page renders 500.astro for the same reason.
At the HTTP boundary, statusFor and messageFor (in application/http/failure-http.ts) map a Failure to a status code and a user-facing message, and errorBodyFor builds the JSON error body from them: the message as error, the failure's kind, and the field of an InvalidInput. This is the only place that mapping lives:
| Failure | Typical cause | statusFor |
messageFor |
|---|---|---|---|
InvalidInput |
malformed username or year | 400 |
the failure's message
|
NotFound |
GitHub returned 404 for the user | 404 |
"User not found" |
Network |
GitHub unreachable or too slow | 502 |
"Could not reach GitHub" |
Upstream |
GitHub answered with a status other than 200, 404 and 429 | 502 |
the failure's message
|
Parse |
HTML structure changed, no Contribution Days found | 502 |
the failure's message
|
RateLimited |
GitHub answered 429
|
429 |
the failure's message
|
Delivery |
Email Routing refused a Contact Message | 502 |
"Could not send your message" |
A RateLimited also emits a Retry-After, through retryAfterHeader: the figure GitHub named, in whichever of the two RFC forms it used, and no header at all when it named none.
Only a status of 500 or above is logged, through Cloudflare's export to Better Stack, with the username, failure kind, reason, status, and endpoint (api, svg or page; the landing page logs its own 5xx too). A 400, a 404 and a 429, GitHub's included, are not logged.
- Architecture describes the layers behind these flows.
- API Reference lists every endpoint, parameter and status.
- Mobile App follows the app's own fetch, cache, refresh and Home Screen Widget.