Skip to content

feat(stats): server-side GitHub stats cache + client swap (stop burning visitor rate limit)#61

Merged
LakshmanTurlapati merged 4 commits into
mainfrom
feat/server-side-github-stats-cache
May 16, 2026
Merged

feat(stats): server-side GitHub stats cache + client swap (stop burning visitor rate limit)#61
LakshmanTurlapati merged 4 commits into
mainfrom
feat/server-side-github-stats-cache

Conversation

@LakshmanTurlapati

Copy link
Copy Markdown
Collaborator

Summary

Move all GitHub API calls off the Angular client onto a server-side 5-min poller backed by SQLite. The /stats page now reads from same-origin /api/public-stats/github/:endpoint_id instead of api.github.com directly. Stops every visitor's browser from burning the shared 60-req/hr unauthenticated GitHub rate limit and lets one authenticated server poller use the 5000-req/hr quota.

Why now

Quick task 260514-r6i had to widen the showcase CSP to allow api.github.com because /stats was hitting GitHub from the visitor's browser. Under any non-trivial traffic the shared per-IP rate limit gets exhausted and charts go dark. The right pattern is: server polls once, persists, serves from cache; this PR implements that.

Design

Cache table (showcase/server/src/db/schema.js)
github_cache keyed by endpoint_id PRIMARY KEY, with payload_json / etag / fetched_at / http_status / rate_limit_remaining / rate_limit_reset. Added inside the existing idempotent CREATE TABLE IF NOT EXISTS block so deploy auto-creates it on the Fly volume.

Poller (showcase/server/src/telemetry/github-poller.js)
Mirrors the existing housekeeper.js cron pattern: setImmediate first tick + setInterval every 5 minutes, defensive try/catch wrapping each endpoint and the outer tick body (never throws), pollerBusy overlap guard. Reads GITHUB_TOKEN once at module load — works with (5000/hr authenticated) and without (60/hr unauthenticated, single boot warning, no per-tick noise). 7 endpoints: repo-summary, stars, issues, forks, pulls, commits, releases. If-None-Match ETag round-trip turns steady-state cycles into mostly-304s that don't count against the rate budget.

Endpoint (showcase/server/src/routes/public-stats.js)
New sub-route GET /api/public-stats/github/:endpoint_id mirroring the existing /global patterns: in-process Map memo, sha256 ETag, Cache-Control: public, max-age=60, defensive res.removeHeader('Set-Cookie'), allowlist guard returning 400 on unknown endpoint_id, 503 + Retry-After: 60 on cold cache, 304 on If-None-Match match.

Angular swap (showcase/angular/src/app/core/stats/github-stats.service.ts)
API_BASE, MAX_PAGES, PER_PAGE, fetchPaginated, emitRateLimited, the 403 rate-limit branch, and the X-GitHub-Api-Version header are all gone. API_ROOT = '/api/public-stats/github' with credentials: 'same-origin'. Aggregator functions and component consumers are unchanged.

CSP cleanup (showcase/server/server.js:63)
connect-src tightened from 'self' https://api.github.com to 'self'. The now-obsolete tests/showcase-csp-allows-github-api.test.js is deleted and removed from the npm test chain.

Tests

  • tests/server-github-cache.test.js (NEW, 17/17 PASS): 200+ETag round-trip, 304 path, 503+Retry-After on cold cache, 400 on unknown id, no Set-Cookie leak, memo rebuild.
  • tests/server-github-poller.test.js (NEW, 31/31 PASS): tick upserts, 304 preserves payload, error resilience, GITHUB_TOKEN header on/off, pagination concat, allowlist invariant, T8 source-grep guard that MAX_PAGES_COMMITS >= 30 (regression migrated from the now-irrelevant client-side MAX_PAGES = 30 assertion in cumulative-commits-aggregator.test.js).
  • tests/cumulative-commits-aggregator.test.js: trimmed by 2 assertions (MAX_PAGES moved to poller test); aggregator function coverage unchanged.

Validation gates

  • node scripts/validate-extension.mjs — OK (manifest valid, 245 JS files parsed clean)
  • npm --prefix showcase/angular run build — exit 0
  • npm test — exit 0 (full root suite)

Verifier verdict

human_needed — 13/15 code-level truths verified by static analysis + unit tests; 2 truths route to post-deploy DevTools confirmation:

  1. Visitor's browser makes ZERO api.github.com requests on /stats (provable indirectly via CSP + service source, final confirmation needs Network panel inspection).
  2. All /stats charts render with real data after the first server poll cycle (aggregator code unchanged, cache test asserts byte-for-byte payload preservation, but no automated SVG output check).

Operational follow-up (user-gated)

Set the GitHub token on Fly so the poller uses the 5000/hr authenticated quota instead of the 60/hr unauthenticated fallback:

fly secrets set GITHUB_TOKEN=<github-token-with-public_repo-read-scope> -a fsb-server

The poller works without it (single boot warning, falls to unauthenticated 60/hr — still vastly better than every visitor sharing that pool). Do NOT put the token in fly.toml.

Test plan

  • Merge and deploy.
  • Set GITHUB_TOKEN via fly secrets.
  • Confirm /api/public-stats/github/repo-summary returns 200 after the first 5-min tick.
  • Open /stats with DevTools Network panel filter api.github.com — confirm ZERO requests, all GitHub data sourced from same-origin.
  • Confirm charts render unchanged.

🤖 Generated with Claude Code

LakshmanTurlapati and others added 4 commits May 16, 2026 05:59
- Add github_cache SQLite table (1 row per endpoint_id, idempotent IF NOT EXISTS)
- Add 4 prepared statements + 4 public wrappers in queries.js
- New src/telemetry/github-poller.js mirroring housekeeper.js (5min interval,
  setImmediate first tick, busy guard, ETag round-trips, never throws, reads
  GITHUB_TOKEN once at boot, single boot warning when absent)
- Extend public-stats router with GET /github/:endpoint_id (memo + ETag +
  Set-Cookie strip; 400 on allowlist miss, 503 + Retry-After on cache cold,
  304 on If-None-Match match, 200 with cached JSON otherwise)
- Wire startGithubPoller(db) next to startHousekeeper(db); clear interval in
  SIGINT + SIGTERM handlers
- Tighten CSP connect-src back to 'self' (api.github.com removed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ndpoint

- Replace API_BASE='https://api.github.com' + per-endpoint URLs with
  API_ROOT='/api/public-stats/github' single-line constant
- Delete fetchPaginated (server returns concatenated arrays in one response)
- Delete 403 + X-RateLimit rate-limit branch (dead code post-swap)
- Delete emitRateLimited helper (no rate-limited subject emissions possible)
- Drop X-GitHub-Api-Version + application/vnd.github vendor Accept headers;
  server handles all GitHub-side specifics
- Add 503 -> null path so first-boot cache cold is treated as transient
- Update file-header design notes block accordingly
- Aggregator functions UNCHANGED (cumulativeStarsSeries etc.) -- raw GitHub
  JSON shape preserved verbatim by the server
- Switch fetch to credentials: 'same-origin' (was 'omit')
- Delete tests/showcase-csp-allows-github-api.test.js (obsolete regression
  guard for the now-reverted CSP api.github.com allowance)
- Remove the deleted test from package.json scripts.test chain

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ests

- tests/server-github-cache.test.js: 17 assertions across T1-T6
  - T1: seeded GET /github/repo-summary -> 200 + ETag + Cache-Control + body
  - T2: If-None-Match round-trip -> 304 empty body, ETag preserved
  - T3: cache-cold GET -> 503 + Retry-After: 60 + JSON error shape
  - T4: unknown endpoint_id -> 400 allowlist rejection
  - T5: stars endpoint serves array; no Set-Cookie defensive check
  - T6: _resetMemoForTest forces DB rebuild; new ETag differs

- tests/server-github-poller.test.js: 29 assertions across T1-T7
  - T1: full happy-path tick caches all 7 endpoint_ids with status=200
  - T2: 304 path preserves payload_json, bumps fetched_at, sets status=304
  - T3: one endpoint rejection -> tick never throws; others still upsert
  - T4: GITHUB_TOKEN set -> Authorization: Bearer header sent
  - T5: GITHUB_TOKEN unset -> no Authorization header
  - T6: commits pagination concatenates pages 1+2+3 = 250 elements
  - T7: GITHUB_ENDPOINT_IDS allowlist invariant (length 7, exact membership)
  - Uses require.cache deletion to re-evaluate GITHUB_TOKEN per scenario

- Wire both new tests into package.json scripts.test chain immediately
  after server-public-stats-no-auth.test.js (before fsb-telemetry-service)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ests

After 260516-7l5 the Angular client no longer paginates against GitHub;
the server-side poller does the walk. Drop the now-defunct
`MAX_PAGES = 30` assertions from tests/cumulative-commits-aggregator.test.js
and move the equivalent regression guard to tests/server-github-poller.test.js
(new T8: source-grep MAX_PAGES_COMMITS >= 30 in github-poller.js).

The cumulative-commits aggregator function itself is unchanged and still
guarded by tests 2-12 in that file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@LakshmanTurlapati LakshmanTurlapati merged commit d587a59 into main May 16, 2026
8 checks passed
@LakshmanTurlapati LakshmanTurlapati deleted the feat/server-side-github-stats-cache branch May 16, 2026 11:14
LakshmanTurlapati added a commit that referenced this pull request May 18, 2026
* docs: start milestone v0.9.70 Showcase Dashboard Reliability (Streaming + Sync + Viewport)

Three target features: (1) diagnose-first dashboard DOM-streaming fix
continuing STREAM-07 attempt 2 of 5 from v0.9.69 Phase 276; (2) Sync-tab
remote-control restoration on full-selfbrowsing.com; (3) 16:10 desktop
viewport lock on the dashboard preview pane (mobile out of scope).

PROJECT.md "Current Milestone" rewritten to v0.9.70 with goals + target
features + key constraints + deferred candidates. Footer "Last updated"
bumped to 2026-05-16. "Last shipped" updated to reflect v0.9.69 SHIP
+ four post-milestone fixes (PR #57/59/61/62/63/64) and the published
artifacts (extension v0.9.67 zip, mcp-v0.9.2 npm, Fly auto-deploy).

STATE.md frontmatter reset (milestone v0.9.70, status in_progress,
progress 0%). Current Position set to "Not started (defining requirements)".
Active Milestone Risk Register replaced with Active Milestone
Carry-Forward (STREAM-07 attempt 2 + CWS click-through). Quick Tasks
Completed table extended with PRs #59 / #61 / #62 / #63 / #64. Pending
User-Gated Actions: v0.9.0 npm publish marked superseded by v0.9.2;
added v0.9.67 -> extension-v0.9.67 retag decision.

Phases dir cleared (8 v0.9.69 phase directories removed by
gsd-tools phases clear). Carries over: 999.1-mcp-tool-gaps,
INTEGRATION-CHECK.md, v0.9.63-INTEGRATION-CHECK.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(showcase): lock dashboard preview pane to 16:10 on desktop

Phase 280 VIEWPORT requirement: the live DOM preview frame in the
showcase dashboard had no fixed aspect ratio and stretched freely to
container height, producing inconsistent letterboxing and the bug
visible in milestone 0.9.70. Pin it to 16/10 on desktop, unset on
mobile (<=768px and <=480px) where vertical layout takes over, and
keep Maximized (100vh) and PiP layouts coherent (auto for maximized,
explicit 16/10 for PiP).

Adds tests/dashboard-preview-aspect-ratio.test.js — parses the SCSS
and asserts the rule lives in the right selectors (desktop, both
mobile media queries, Maximized, PiP). Wired into the root `npm test`
chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant