A static, serverless RSS / Atom / JSON Feed reader. The whole app is a single-page Vite + React build that runs entirely in the browser, storing feeds, items, and Reader Mode articles in IndexedDB. The only piece of backend is a ~150-line Cloudflare Worker that proxies outbound feed and article fetches so the browser can get around CORS.
┌──────────────────────────────┐ ┌────────────────────────────┐
│ GitHub Pages (static SPA) │ ──────▶ │ Cloudflare Worker proxy │ ──▶ feeds
│ IndexedDB, Readability, │ │ SSRF-guarded GET-only │
│ feed parsing — all in-browser│ └────────────────────────────┘
└──────────────────────────────┘
Originally this app shipped a Node + SQLite server. It was rewritten to be fully static so it can live on GitHub Pages. Everything the old server did now happens client-side:
| Old (Node server) | New (browser) |
|---|---|
SQLite (better-sqlite3) |
IndexedDB (client/src/store.ts) |
rss-parser + XML parsing in Node |
DOMParser + custom parser (client/src/parser.ts) |
@mozilla/readability + jsdom |
@mozilla/readability over DOMParser documents |
fetch() from Node (no CORS) |
fetch() through a Cloudflare Worker proxy |
| Node SSRF guards, redirect cap, timeout | Same guards, ported into the Worker |
The one piece that can't live in the browser is the outbound feed
fetch — most RSS endpoints don't send Access-Control-Allow-Origin, so a
GH-Pages-hosted SPA can't fetch() them directly. A ~150-line Cloudflare
Worker handles those fetches and returns the body with permissive CORS.
The Worker is the only thing that needs an account anywhere; the rest is
flat files served by GitHub.
npm run install:all # root, client, and worker deps
# Terminal 1 — local Worker on :8787
npm run worker:dev
# Terminal 2 — Vite on :5173, pointed at the local worker
echo "VITE_PROXY_URL=http://localhost:8787" > client/.env.local
npm run devOpen http://localhost:5173. On first load with an empty IndexedDB the client subscribes to six default feeds (Hacker News, Lobste.rs, The Verge, Ars Technica, Daring Fireball, BBC Tech). To reset, clear site data in DevTools → Application → Storage.
client/.env.local is gitignored. For production builds the GH Actions
workflow reads VITE_PROXY_URL from a repo variable instead (see below).
The deploy is two independent moving parts:
- Cloudflare Worker — the CORS proxy (one-time setup + redeploy on
change). Lives at
https://feed-reader-proxy.<subdomain>.workers.dev. - GitHub Pages — the static SPA. A push to
mainrebuilds and redeploys via.github/workflows/deploy.yml.
The Worker proxies fetches with SSRF protection (private-IP block, redirect
cap, scheme allowlist, 15s timeout, 10 MB body cap). Full spec in
worker/README.md.
cd worker
npm install
npx wrangler login # opens browser → authorize with your CF account
npm run deployOne-time: after your first Workers deploy, Cloudflare will warn "You need to register a workers.dev subdomain before publishing". Open https://dash.cloudflare.com/ → Workers & Pages → Overview and pick a subdomain (e.g.
bpasero→bpasero.workers.dev). No redeploy needed — the existing upload becomes reachable immediately.
The Worker URL is https://feed-reader-proxy.<your-subdomain>.workers.dev.
You'll wire this into the client in step 2.
URL=https://feed-reader-proxy.<your-subdomain>.workers.dev
FEED=https%3A%2F%2Fhnrss.org%2Ffrontpage
curl -s "$URL/health"; echo # {"ok":true}
curl -s "$URL/?url=$FEED" | head -c 200; echo # <rss …>
curl -sI -H "Origin: https://bpasero.github.io" "$URL/?url=$FEED" | grep -i access-control-allow-origin # echoes the allowed origin
curl -sI -H "Origin: https://evil.example" "$URL/?url=$FEED" | grep -i access-control-allow-origin # (no output — not allowlisted)
curl -s -o /dev/null -w "%{http_code}\n" "$URL/?url=http%3A%2F%2Flocalhost%2F" # 400 (SSRF blocked)
curl -s -o /dev/null -w "%{http_code}\n" "$URL/?url=http%3A%2F%2F%5B%3A%3Affff%3A7f00%3A1%5D%2F" # 400 (IPv4-mapped IPv6 blocked)The Worker only sets Access-Control-Allow-Origin for browser requests from
allowlisted origins (http://localhost:5173, http://127.0.0.1:5173,
https://bpasero.github.io — edit worker/src/worker.ts to change). Non-
browser clients (curl, scripts) work without a CORS header.
- Settings → Pages → Build and deployment → Source = GitHub Actions.
- Settings → Secrets and variables → Actions → Variables → New repository
variable:
VITE_PROXY_URL= your Worker URL from step 1. - Push to
main(or click Run workflow onDeploy to GitHub Pages). The workflow builds withbase: '/claude-one/'and publishes tohttps://<user>.github.io/claude-one/.
If you fork to a differently-named repo, edit the base in
client/vite.config.ts to match.
Open the deployed URL with DevTools → Network. Feed requests should be
GET https://feed-reader-proxy.<…>.workers.dev/?url=… returning 200 OK
with access-control-allow-origin: *. No CORS errors in the console. The
sidebar should populate after the six default feeds finish fetching.
- Subscribe to any RSS, Atom, or JSON Feed URL. Parsed
client-side with
DOMParser. - Three-column layout: feed sidebar / item list / reader pane. The
reader pane is togglable via the top-right panel icon and the preference
is persisted to
localStorage. - Collapsible sidebar: the top-left panel icon collapses the feed sidebar to a thin rail of avatars (unread feeds get an accent dot). Hovering or keyboard-focusing the rail expands the full sidebar as an overlay above the item list. Opening the inline "+" add-feed form keeps the sidebar expanded.
- Reader Mode: clicking an item triggers a fetch of the source URL
through the Worker proxy, then runs
@mozilla/readabilityin the browser. Extracted articles are cached in IndexedDB (reader_articles). - Reader / Original toggle: segmented control in the reader header. Reader shows the extracted article; Original renders the source page in a sandboxed iframe.
- Tabs + grid view: every opened article becomes a tab; toggle Grid view (≥2 tabs) to see them as tiles. Drag to reorder.
- Light + dark theme via
prefers-color-scheme— OKLCH tokens flip automatically. - Per-feed unread counts, item read/unread state, bulk "Mark all read".
- Manual refresh per feed or all at once.
- Context menus (right-click) on feeds and items.
The only network-exposed piece is the Worker. Defenses in worker/src/worker.ts:
- SSRF: scheme allowlist (http/https only); hostname checks block
literal private/loopback IP ranges (
10/8,127/8,169.254/16,172.16-31/12,192.168/16,100.64/10CGNAT),localhost,*.local,*.internal,*.localhost, IPv6 loopback/link-local/ULA, and IPv4-mapped-IPv6 forms ([::ffff:7f00:1]). The WHATWG URL parser normalizes alternate IPv4 encodings (decimal, hex, octal) before the check runs. Redirect cap of 5 with re-validation per hop. - Method allowlist: only
GETandOPTIONSreach upstream. - Resource limits: 15s upstream timeout, 10 MB body cap.
- CORS allowlist:
Access-Control-Allow-Originis only set when the request'sOriginheader matchesALLOWED_ORIGINS(configurable at the top ofworker/src/worker.ts). Non-browser clients work without it.
The client sanitizes all rendered feed and reader content with DOMPurify before injecting into the DOM.
Both projects have Vitest suites — 133 tests total, no network and no real filesystem touched.
npm test # client + worker
npm run test:client # client only
npm run test:worker # worker only
npm --prefix client run test:watch # client watch mode
npm --prefix client run test:coverage # v8 coverage reportWhat's covered:
worker/src/worker.test.ts(79 tests) — every SSRF case (private IPv4 ranges including CGNAT; IPv6 loopback / link-local / ULA; IPv4-mapped-IPv6 in both dotted and compressed-hex form; alternate IPv4 encodings;localhost/.local/.internal/.localhost); the CORS origin allowlist; the full HTTP handler (OPTIONS, GET, POST,/health, missing url, scheme allowlist, redirects to public + private, too many redirects, missing Location, body size cap via both Content-Length and actual bytes, upstream errors).client/src/parser.test.ts(24 tests) — RSS 2.0 (withcontent:encoded,dc:creator, missing-field fallbacks); Atom (link rel=alternate,author/name,published→updatedfallback,content→summaryfallback); JSON Feed (content_htmlvscontent_text,urlvsexternal_url,authors[]vsauthor, version validation); malformed XML, unsupported root, RSS missing channel.client/src/store.test.ts(23 tests) — IndexedDB CRUD, unique-URL constraint, cascading delete (feed → items → reader articles), upsert by(feed_id, guid), sort bypublished_at, read/unread filtering,markAllReadwith and withoutfeedId, unread-count aggregation, reader article round-trip. Usesfake-indexeddb— no browser needed.client/src/proxy.test.ts(7 tests) — env-var configured / unconfigured, URL encoding of the target,X-Final-URLheader, non-2xx error surfacing, empty-body fallback to statusText.
.
├── client/ # Vite + React + TypeScript
│ ├── src/
│ │ ├── App.tsx # main UI
│ │ ├── api.ts # facade over store + proxy + parser + reader
│ │ ├── store.ts # IndexedDB wrapper (feeds, items, reader_articles)
│ │ ├── store.test.ts # IndexedDB CRUD tests (fake-indexeddb)
│ │ ├── parser.ts # RSS / Atom / JSON Feed parsing via DOMParser
│ │ ├── parser.test.ts # parser format coverage
│ │ ├── reader.ts # @mozilla/readability extraction
│ │ ├── proxy.ts # client wrapper around the Worker proxy
│ │ ├── proxy.test.ts # proxy wrapper tests (mocked fetch)
│ │ ├── seed.ts # default feed list for first-run
│ │ └── styles.css # OKLCH light/dark theme
│ ├── vite.config.ts # base = '/claude-one/' in production
│ └── vitest.config.ts # jsdom env + fake-indexeddb setup
├── worker/ # Cloudflare Worker (CORS / SSRF-guarded proxy)
│ └── src/
│ ├── worker.ts
│ └── worker.test.ts # SSRF, CORS, handler tests
├── .github/workflows/
│ ├── deploy.yml # main: test → build → deploy to GitHub Pages
│ └── test.yml # PR / branch pushes: test only
└── package.json # root scripts
| Command | What it does |
|---|---|
npm run install:all |
Install root, client, and worker deps |
npm run dev |
Run the Vite dev server (5173) |
npm run build |
Production build of the client (client/dist/) |
npm run preview |
Preview the production build locally |
npm test |
Run all client + worker tests |
npm run test:client |
Run client tests only |
npm run test:worker |
Run worker tests only |
npm run worker:dev |
Run the Worker locally via wrangler dev |
npm run worker:deploy |
Deploy the Worker to your Cloudflare account |
- Node 20+
- A Cloudflare account (Workers free tier is plenty)