17 KiB
Fusion (0x2e/fusion) — Repository Analysis
This report summarizes the Fusion self-hosted RSS reader: its stack, dependencies, and a practical roadmap to reimplement parity using Vapor (Swift) for the backend and React for the frontend.
1. What Fusion Is
- Product: Lightweight RSS/Atom reader with unread tracking, bookmarks, search, keyboard shortcuts, groups, and PWA support.
- Distribution: Single Go binary that embeds the built SPA, or Docker / Fly.io; data in SQLite.
- Compatibility: Fever API for third-party clients (Reeder, Unread, FeedMe, etc.).
- Auth: Password sessions (cookie
session) plus optional OIDC SSO. - Contract: HTTP JSON API under
/apidocumented indocs/openapi.yaml.
2. Technology Stack (Upstream)
| Layer | Technology |
|---|---|
| Backend language | Go (module declares go 1.26; README/dev docs mention 1.25+) |
| HTTP | Gin |
| Database | SQLite via modernc.org/sqlite (pure Go, no CGO in release builds) |
| Migrations | Embedded .sql files |
| Feed parsing | gofeed |
| Feed discovery | feedfinder |
| Auth | golang.org/x/crypto (password hashing), go-oidc, golang.org/x/oauth2 |
| Frontend | React 19, TypeScript, Vite 7 |
| Routing | TanStack Router |
| Server state / cache | TanStack Query |
| Local UI state | Zustand |
| Styling | Tailwind CSS 4, shadcn/ui, Radix primitives |
| Other FE | DOMPurify (HTML sanitization), next-themes, Lucide icons, Sonner toasts, cmdk (command palette) |
Build pipeline: scripts.sh runs pnpm in frontend/, copies dist/ into backend/internal/web/dist/, then go build embeds the static assets into one binary.
3. Backend Dependencies (backend/go.mod)
Direct require entries:
| Module | Role |
|---|---|
github.com/0x2E/feedfinder |
Discover RSS/Atom URLs from a site |
github.com/coreos/go-oidc/v3 |
OIDC token validation |
github.com/gin-gonic/gin |
HTTP router/middleware |
github.com/google/uuid |
IDs (sessions, etc.) |
github.com/mattn/go-isatty |
TTY detection (logging/CLI) |
github.com/mmcdole/gofeed |
RSS/Atom parsing |
golang.org/x/crypto |
Password hashing |
golang.org/x/oauth2 |
OAuth2 client for OIDC |
golang.org/x/sync |
Concurrency helpers (e.g. errgroup/limits) |
modernc.org/sqlite |
SQLite driver |
Go pulls a standard set of indirect dependencies (JSON binding, validation, HTTP/2 QUIC stack used by Gin’s stack, etc.) — see the full require block in the repo for the exact pinned versions.
4. Frontend Dependencies (frontend/package.json)
Runtime (dependencies) — highlights:
- React:
react,react-dom(~19.2) - Build-time CSS:
@tailwindcss/vite,tailwindcss,tw-animate-css - Data:
@tanstack/react-query,@tanstack/react-router - State:
zustand - UI: extensive
@radix-ui/*,shadcn,class-variance-authority,clsx,tailwind-merge,cmdk,sonner,lucide-react,@fontsource-variable/inter - Security / content:
dompurify
Dev (devDependencies): TypeScript, ESLint, Vite, @vitejs/plugin-react, TanStack Router CLI/plugin, Tailwind typography, type packages.
Package manager: pnpm (lockfile: frontend/pnpm-lock.yaml).
5. Functional Surface You Must Reproduce
5.1 REST API (/api/*)
Source of truth: docs/openapi.yaml. Areas include:
- Sessions:
POST /api/sessions(login),DELETE /api/sessions(logout); session cookie auth for protected routes. - OIDC:
/api/oidc/enabled,/api/oidc/login,/api/oidc/callback(redirect flow). - CRUD: groups, feeds (including validate, batch create, refresh), items (list, mark read/unread), bookmarks.
- Search: feed + item search (backend uses SQLite FTS5 on items).
JSON envelope and error shapes are defined in OpenAPI — match them if you want a drop-in React client.
5.2 Fever API
- POST
/fever,/fever/,/fever.phpwithapplication/x-www-form-urlencoded. - Auth:
api_key = md5(username:password)perdocs/fever-api.md. - Optional parity for mobile/desktop clients.
5.3 Background work: feed puller
The Go process runs two long-lived concerns in one binary:
- HTTP server
- Periodic + manual feed fetch worker: concurrency limits, timeouts, conditional requests (
ETag,Last-Modified), backoff,Retry-After, cache headers, per-feedfeed_fetch_state(seedocs/backend-design.md).
Any Vapor port needs the same scheduling semantics (or document differences).
5.4 Data model
- groups, feeds, feed_fetch_state, items, bookmarks, items_fts (FTS5 + triggers), schema migrations
001/002underbackend/internal/store/migrations/. - Legacy DB import path exists in Go; a greenfield Vapor app can start from the current schema only.
5.5 Security / ops behavior
- SSRF-style protections for feed URLs (private IP blocking unless explicitly allowed) — see
internal/pkg/httpcpatterns in the Go tree. - CORS, trusted proxies, login rate limiting, logging — env-driven (see
.env.examplein the repo:FUSION_*variables).
6. Reproducing the Project with Vapor (Swift) + React
This is a full reimplementation, not a thin wrapper. The sections below list what to build and which Swift/React ecosystem pieces typically map to the Go/TS stack.
6.1 Backend (Vapor)
-
HTTP API parity
- Implement routes and JSON models to match
docs/openapi.yaml(or generate Swift types from OpenAPI with a code generator and fill in handlers). - Session middleware: issue/validate session cookie named
session; match login rate limits and 401 behavior expected by the existing frontend.
- Implement routes and JSON models to match
-
SQLite
- Use Fluent + SQLite or raw SQL via SQLite.swift / GRDB — Fusion relies on FTS5 and triggers; confirm your Swift SQLite build enables FTS5.
- Port migrations from
001_initial.sqland002_feed_fetch_state.sql(and any later migrations in the repo). - Reproduce cascade / move rules** for group/feed deletion** as documented in
docs/backend-design.md(explicit transactions, not onlyON DELETE).
-
Feed ingestion
- Parse: FeedKit or similar for RSS/Atom.
- Discovery: port or call feed discovery behavior equivalent to feedfinder (HTTP fetch + HTML link rel discovery).
- HTTP client: custom timeouts, redirect limits, and private-IP blocking mirroring Fusion’s policy unless you intentionally differ.
-
Background scheduler
- Run the pull loop in a Vapor
Applicationlifecycle task** or a separate Swift executable that shares the DB file (single-writer caution: use one writer or WAL with careful locking). - Implement the same interval, concurrency, timeout, max backoff, conditional GET, and
feed_fetch_stateupdates described in backend design docs.
- Run the pull loop in a Vapor
-
Auth
- Password: hash with a modern KDF (Argon2id or bcrypt) — match whatever the OpenAPI responses assume for “invalid password” flows.
- OIDC: use an OAuth2/OIDC Swift client; implement the same redirect URI contract (
/api/oidc/callback).
-
Fever (optional but for full parity)
- Add form-encoded POST handlers and response JSON matching Fever clients’ expectations (
docs/fever-api.md).
- Add form-encoded POST handlers and response JSON matching Fever clients’ expectations (
-
Static hosting
- Either serve the React build from Vapor’s
Public/FileMiddleware(like the embedded Godist/) or deploy the SPA to a CDN and setVITE_API_BASE_URL(or equivalent) to the Vapor origin.
- Either serve the React build from Vapor’s
-
Configuration
- Mirror
FUSION_*semantics from.env.example(port, DB path, CORS, pull tuning, OIDC, Fever username, etc.) using Vapor’s environment layer.
- Mirror
6.2 Frontend (React)
You have two approaches:
| Approach | Effort | Outcome |
|---|---|---|
| Port the existing UI | High | Rebuild routes/components; upstream uses TanStack Router — map to React Router v6+ or keep TanStack Router. |
| New React app, same API | Medium–High | Generate a TypeScript client from openapi.yaml; reimplement layout (sidebar, drawer, keyboard shortcuts, i18n, PWA). |
Must-haves for behavioral parity (from docs/frontend-design.md and the code):
- URL-driven state:
/:filter,/feeds/:feedId/:filter,/groups/:groupId/:filter,?article=for drawer. - API client:
fetchwithcredentials: "include"and base URL/apiorVITE_API_BASE_URL. - Features: unread/all/starred, infinite list, mark all read, OPML import/export logic (see
frontend/src/lib/opml.ts), bookmarks, search, settings, themes, i18n (multiple locale files underfrontend/src/lib/i18n/). - PWA: service worker / manifest equivalents (
frontend/public/). - Sanitization: DOMPurify (or equivalent) for article HTML.
Dependency mapping (optional):
- TanStack Query → keeps React Query (recommended for parity with caching patterns).
- Zustand → same or Redux/context for minimal UI prefs.
- shadcn + Tailwind → same stack works in any Vite/React app.
6.3 Testing parity
- Contract tests: run the OpenAPI spec through Dredd/Schemathesis or manually curl against Vapor.
- Fever: use the
curlexample indocs/fever-api.md. - Feed pull: integration tests with mock HTTP servers for 200/304,
Retry-After, and failure backoff.
6.4 Estimated complexity
- Backend: largest effort (scheduler + HTTP cache semantics + FTS + auth/OIDC + Fever).
- Frontend: large if you duplicate every screen; smaller if you reuse design docs and OpenAPI but simplify UI.
- Risk: SQLite file locking if you split “API server” and “worker” into two processes without a clear WAL/queue strategy.
7. Reference Files in the Upstream Repo
| Path | Purpose |
|---|---|
docs/openapi.yaml |
API contract |
docs/backend-design.md |
DB, pull algorithm, modules |
docs/frontend-design.md |
Routes, UX, state |
docs/fever-api.md |
Fever compatibility |
backend/go.mod |
Go dependencies |
frontend/package.json |
JS dependencies |
scripts.sh |
Build/embed pipeline |
.env.example |
Configuration surface |
8. Summary
Fusion is a Go + embedded React app with SQLite (FTS5), a session/OIDC API under /api, optional Fever endpoints, and a background feed poller with nuanced HTTP caching and backoff. To reproduce it in Vapor and React, treat docs/openapi.yaml and docs/backend-design.md as the specification: implement the same data model and pull semantics in Swift, expose identical HTTP behavior, then either port or rewrite the React client against that contract — preserving cookie auth, URL structure, and PWA/i18n if full parity is required.
A narrower backend rewrite (Vapor + PostgreSQL, existing React SPA unchanged) is described in section 9.
9. Vapor-only backend + PostgreSQL (recommended direction)
This section refines the plan: rewrite only the backend with Vapor and PostgreSQL, map Fusion’s Go dependencies to Swift equivalents, and use sound scheduling patterns for the feed worker.
9.1 Backend-only Vapor + existing React
Keeping the upstream SPA is a strong option. It already targets /api with JSON and session cookies (OpenAPI). If the Vapor app matches that contract (and Fever routes if mobile clients matter), you do not need to rewrite the frontend. Configure CORS, cookie SameSite, and reverse-proxy headers the same way Fusion documents for split origins (SPA on one host, API on another).
9.2 PostgreSQL instead of SQLite
| SQLite (Fusion) | PostgreSQL |
|---|---|
FTS5 + triggers on items |
tsvector + GIN index, or a GENERATED column + index; keep rows in sync via trigger (closest to Fusion) or on write in application code |
| Single file, typical single-writer | Connection pool; Fluent migrations or versioned SQL |
| Unix-epoch integers in schema | Prefer timestamptz where wall time matters; you can still expose epoch ints in JSON for API compatibility |
PostgreSQL full-text search is the natural replacement for FTS5. Triggers preserve the same “index always matches items” invariant as Fusion’s SQLite triggers.
9.3 Swift / Vapor analogs for Go backend dependencies
| Go (Fusion) | Swift / Vapor role |
|---|---|
| Gin | Vapor routing, middleware, Request / Response |
| modernc.org/sqlite | Fluent + fluent-postgres-driver, or postgres-nio / SQLKit if you prefer raw SQL |
| Embedded SQL migrations | Fluent migrations, SQL executed from migrations, or external migration tooling — the pattern stays valid |
| gofeed | FeedKit (RSS/Atom); validate real-world feeds (encoding, CDATA) |
| feedfinder | No common 1:1 Swift package; async-http-client + SwiftSoup (or similar) for HTML and <link rel="alternate">; optional small port of the same discovery rules |
| go-oidc + oauth2 | Provider-specific or generic OAuth2/OIDC flows; validate tokens / JWKS (e.g. JWTKit) depending on provider; exact stack follows your IdP |
| golang.org/x/crypto (passwords) | BCrypt or Argon2 Swift bindings; align cost parameters and any password-migration story |
| google/uuid | Foundation UUID |
| Session cookie | Vapor Sessions with a store appropriate for production (Postgres or Redis) |
| Fever | No extra framework: application/x-www-form-urlencoded, MD5 api_key, response DTOs per docs/fever-api.md |
9.4 Scheduled tasks and feed polling (best practice on Vapor)
Fusion’s worker is database-driven, not “one cron job per feed”: each feed has next_check_at (and related state); a loop selects due feeds, respects concurrency, timeouts, and backoff, then updates rows.
Recommended layering
- Driver tick — One scheduled job (or a short-interval repeat) runs every N seconds (e.g. 30–60s). It does not schedule per URL in memory.
- Work selection — Query feeds that are due (
next_check_at,retry_after_until,suspended, etc.), same semantics asdocs/backend-design.md. - Concurrency — Cap parallel fetches with
TaskGroup(or equivalent) to mirrorFUSION_PULL_CONCURRENCY.
Libraries: Vapor Queues
- Job types, retries,
startScheduledJobs()for cron-like recurrence, workers as separate processes (e.g.swift run App queues). - Production often uses the official Redis driver for the queue backend.
- Community Fluent/Postgres queue drivers exist if you want the database as the queue (e.g.
SKIP LOCKEDfor competing workers) and fewer moving parts than Redis.
How Queues fits Fusion
- Queues helps with orchestration: retries, worker processes, optional horizontal scale.
- Postgres (per-feed
next_check_at,feed_fetch_state, cache metadata) remains the source of truth for when and how to pull — same as upstream; do not rely on the queue alone to remember HTTP caching rules.
Patterns to avoid
- One
Timerper feed in process memory — does not survive restarts and diverges from Fusion’s DB-backed schedule. - Blocking the request thread for long fetches — keep pulls in async services and/or queue workers.
Deployment options
| Pattern | When to use |
|---|---|
| Scheduled Queues job (e.g. every minute) + SQL “due feeds” query | Closest to “single binary + shared state” |
Enqueue RefreshFeedJob(feedId) for each due row |
Scale multiple workers; Redis or Postgres-backed queue |
| External cron hitting an internal “trigger pull” route | Simple ops; guard with DB advisory lock or a single designated worker |
9.5 Summary (Vapor + Postgres path)
- Vapor backend + PostgreSQL + unchanged React is a sound architecture if
docs/openapi.yamlis honored and FTS is implemented astsvector+ GIN (plus triggers or app-level sync). - Go → Swift mapping above covers the main backend crates; OIDC is the area where the choice depends on the identity provider, not a mechanical port.
- Scheduling: treat the puller as DB-scheduled batches; use Vapor Queues (scheduled job + optional Redis/Fluent workers), not per-feed cron or naive timers.
Generated from analysis of github.com/0x2e/fusion (shallow clone for file inspection). Version pins and minor details may change on main; verify the live repo before locking production dependencies.