A self-hosted feed reader that is Go all the way down.
The server is Go. The client is Go compiled to WebAssembly. The CSS is Go.
The transport is real gRPC — in the browser. The only JavaScript that ships is a boot shim.
There is a live demo — the real reader, with an invented instance compiled into the same WebAssembly module. No account, no server, nothing stored: the "server" is the browser tab, and closing it is the undo button. Reading, marking, starring, notes, tags, categories, search, subscribing and mark-all-read-with-undo all work.
It is the shipping client, not a mock-up of it — same components, same client, same generated gRPC
stubs, with only the transport swapped underneath. docs/DEMO.md explains how, and
what it deliberately cannot do (anything needing a server: Smart+, translation, the page proxy).
Google Reader died in 2013 and nothing replaced it. What replaced it was a choice between somebody else's server holding your reading history, or a self-hosted PHP app from 2011 with a mobile site that does not work.
ArticleFlux is the third option. One binary, one SQLite file, no runtime dependencies, and a client that is a real application rather than a page — virtualised lists at firehose scale, a keyboard map your fingers already know, full-text search, tags, notes, offline-capable delivery, and text to speech.
The interesting part is not the feature list. It is that a browser application of this size was
written entirely in Go, with no JavaScript framework, no bundler, no npm install, no TypeScript
build, and no REST layer translating between the two halves — and that the two libraries which make
that possible, GoWebComponents and
GoGRPCBridge, are reusable in any project that wants the
same deal.
./scripts/make.ps1 build
./bin/articleflux.exe seed # subscribe to a starter set and fetch it
./scripts/make.ps1 dev # http://127.0.0.1:9000./scripts/make.ps1 dev # build the client and serve it
./scripts/make.ps1 e2e # Playwright, desktop + phone, against a real server
./scripts/make.ps1 test # go test ./...
./scripts/make.ps1 lint # go vet, buf lint, and the five structural guards
./scripts/make.ps1 demo # the static demo into bin/demo (see docs/DEMO.md)dev serves the local account with no login — the client asks the server at boot and goes
straight to the reader, no password prompt. It is refused on any bind but loopback, and refused
alongside -behind-proxy. cp .env.example .env documents the same switch as ARTICLEFLUX_DEV=1,
along with the dev credentials (cam / articleflux-dev) for when you want to exercise the login
screen itself — and on a loopback origin the login screen prefills both fields with them, so it is
one click.
On Linux the same verbs exist as a Makefile — make dev, make test, make lint. The two are
kept identical on purpose; two build systems is only a lie if they disagree.
No Node for the app. No Docker. No WSL. Node appears exactly once, in the e2e suite, because Playwright drives a real browser.
deploy/README.md takes a bare Ubuntu droplet to a reader on TLS in about
twenty minutes: systemd unit, nginx site with the WebSocket settings the tunnel needs, certbot,
and a nightly verified backup. Ships with it:
deploy/articleflux.service |
Hardened systemd unit — loopback bind, unprivileged user, ProtectSystem=strict |
deploy/nginx.conf |
The /grpc block, whose four settings are the difference between a working tunnel and one that drops every sixty seconds |
deploy/articleflux-backup.{service,timer} |
VACUUM INTO + PRAGMA integrity_check nightly, because a cp of a WAL-mode database restores cleanly and is silently missing a transaction |
A hosted instance requires a login. Create the first account once:
articleflux init -db /var/lib/articleflux/articleflux.db -user camThe server refuses to start rather than serving a login screen nobody can get past — along with
two other boot checks (missing web root, unwritable data directory) that otherwise surface hours
later while /healthz reports green.
ArticleFlux is the proof that these two work together on something real, and it is small enough to read. If you only take one thing from this repository, take one of these.
github.com/monstercameron/GoWebComponents
A Go + WebAssembly UI framework with a React-shaped component model: hooks, a fiber runtime, typed HTML builders, client-side routing, shared state, streaming SSR with hydration, crash containment, i18n, PWA support — all in one Go module.
The part that changes how a project feels is the CSS engine. Styles are Go values, compile-checked, colocated with the component:
css.Rule(".row",
css.Custom("--c", css.Var("hue")),
css.BorderLeft("3px solid", css.Var("c")),
css.Media("(max-width: 720px)", css.Padding("0.5rem")),
)There is not one .css file in this repository, and CI fails if one appears. There is not one
application .js file either. The stylesheet, the layout, the interaction and the state are the same
language as the query planner — which means a rename is a compiler error rather than a bug report,
and a designer's change lands in client/design/sheet.go next to the component it styles.
What it costs, honestly: the wasm bundle is 23.8 MB, 5.2 MB gzipped, and CI fails the build if it grows more than 5% without someone bumping the baseline on purpose. That is the trade: a large first load, cached thereafter, in exchange for deleting an entire toolchain. ArticleFlux decided the trade was worth it and made the Service Worker load-bearing. Decide it yourself before adopting — the framework's README says the same thing.
github.com/monstercameron/GoGRPCBridge
Browsers cannot open raw TCP sockets, so a standard gRPC client does not work in a js/wasm build.
The usual answer is to give up and write a REST layer — hand-rolled JSON on both sides, drifting
silently, with the contract living in a wiki page.
GoGRPCBridge tunnels HTTP/2 gRPC frames over a WebSocket instead:
Browser (Go WASM gRPC client)
└─ WebSocket
└─ grpctunnel bridge handler (net/http)
└─ HTTP/2 ⇄ in-process grpc.Server
└─ your existing protobuf services
Your .proto files do not change. Your generated clients do not change. Server integration is
grpctunnel.Wrap(grpcServer), which is an http.Handler. All four RPC types work — unary,
server-streaming, client-streaming, bidirectional — plus deadlines, metadata and interceptors. It
ships origin allowlists, pre-upgrade authorization, connection caps, keepalive with transparent
reconnection, OpenTelemetry spans, and a native-transport mode that is 47% lighter on memory per RPC.
In ArticleFlux this is what the connected dot in the toolbar reports, and it is why the client and the
server cannot disagree about a field name: proto/articleflux/v1 is the only contract, and both ends
are generated from it. Twenty-four RPCs across two services, all unary today; the tunnel is ready
for streaming when the ranking layer needs it.
cmd/articleflux the binary: serve · seed · poll · version
internal/store ALL SQL lives here. Two pools: many readers, one writer
internal/feed fetch + normalise. Every fetch goes through the SSRF guard
internal/reader the service layer — one place that knows what "mark read" means
internal/transport gRPC, thin: message translation and the error taxonomy
client/ the wasm app. design/ (tokens + stylesheet), view/, data/, platform/
proto/ the contract all transports share
Four decisions shape most of the code:
- Sources and items are global and deduplicated. One popular feed is polled once no matter how
many people subscribe. That is why read/star state lives in its own table, and why global rows are
never hard-deleted — a cascade from
itemswould destroy every other tenant's history. - Two connection pools. SQLite allows many readers and exactly one writer; modelling that as two pools makes the constraint structural instead of a race.
- All CSS is Go. There is no
.cssfile in this repository and CI fails if one appears. syscall/jslives in exactly one package, so every other client package compiles — and is testable — natively.
Each of the four is enforced by a guard in internal/tools/guards, not by convention. A decision
that is only written down is a decision that erodes; a decision with a build failure attached is a
decision.
| Go tests | 16 packages, run on Windows in CI |
| e2e | Playwright, desktop and phone, against a real server and a real database |
| Design parity | the palette, three type stacks, and one hue reaching all four surfaces, checked mechanically |
| Structural guards | four, all green, run on every push |
| Contract | buf lint plus buf breaking — additive-only within v1, because old clients exist in the wild |
| Bundle size | ratcheted against a checked-in baseline; +5% fails the build |
plan.md is the spec of record, TODO.md the dependency-ordered build, FLOWS.md the nine paths
that are easy to get subtly wrong, docs/FEATURES.md the behaviour of every
capability and whether it exists yet, and design/ the visual specification (hand-written HTML that
nobody ports — the reader is rebuilt from it in Go).
They are kept in sync with the code deliberately, and the precedence rule is written down: if the
implementation contradicts plan.md, the plan is wrong, and it gets corrected in the same change.
A spec that has quietly drifted is worse than no spec, because it still gets trusted.
Start at TODO.md Tier 9, where every milestone has a complete brief: the plan sections that define
it, the pages, the components, the flow diagram, and the tests that must go green.
- GoWebComponents v5 was never tagged, so
go.modcarries areplaceto a sibling checkout. CI materialises it the same way. Remove both once the tag is pushed. - FTS5 is not compiled into
ncruces/go-sqlite3. It is a loadable extension that must be registered on every connection — seeinternal/store.Open. There is a permanent test for this, so a dependency bump that drops FTS5 fails the build instead of silently removing search. Props.Stylecannot set CSS custom properties in GWC — it assigns JS properties, and that path does not reach--vars. Use a style string throughRaw. This silently rendered every hue grey and no test that did not open a browser could see it.- A
func(string)event handler receivesevent.target.value, not the key — so search-on-Enter silently never fired. - The dev server runs without a login, and only ever on a loopback bind. Binding a real interface turns that off, because an internet-facing instance with it on would be an open reader.
Read CONTRIBUTING.md first — the doc discipline and the "when the spec is silent" rule are the whole culture of this repository, and they are short.
MIT. See LICENSE.

