simple-web-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs | README

commit 7c1046bcf16f2596604dc4f27b66d3897d03018e
parent 52dfc51052fd43c9e08fc1977a9f76bca3f6fc7c
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sat, 13 Jun 2026 12:41:03 +0200

refactor: require all env vars, update .env.example and README

Config now panics on startup if any env var is missing. No silent
defaults. .env.example has the complete list.

README rewritten with principles (mechanical sympathy, simplicity,
crash early, minimal deps), architecture notes, performance
numbers, and migration docs.

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

Diffstat:
M.env.example | 9+++++++--
MREADME.md | 86+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Msrc/config.rs | 55++++++++++++++++++++-----------------------------------
3 files changed, 80 insertions(+), 70 deletions(-)

diff --git a/.env.example b/.env.example @@ -1,3 +1,8 @@ -# Application settings +DB_PATH=data/app.db +JWT_SECRET=change-me-to-a-random-string DEBUG=true -DATABASE_URL=sqlite:./db.sqlite3 +PORT=8000 +SMTP_HOST=localhost +SMTP_PORT=25 +SMTP_FROM=noreply@example.com +SITE_URL=http://localhost:8000 diff --git a/README.md b/README.md @@ -2,38 +2,70 @@ A link aggregator built with Rust, Axum, Askama, and SQLite. +## Principles + +- **Mechanical sympathy** — match concurrency to hardware. Thread-local SQLite connections, one per core. No mutex contention on the read path. +- **Simplicity** — no ORMs, no abstractions. SQL strings, direct function calls, linear control flow. A handler reads top-to-bottom: authenticate, query, render, respond. +- **Crash early** — all environment variables are required. Missing config panics on startup, not at request time. No silent defaults. +- **Minimal dependencies** — hand-rolled SMTP, connection pool, and migration system. 9 direct dependencies. +- **No indirection** — no traits "in case we swap later", no generic abstractions over concrete types. + ## Architecture -- **Web framework**: Axum (async Rust) -- **Templates**: Askama (compiled HTML templates) -- **Database**: SQLite via rusqlite (embedded, no separate service) -- **Frontend**: Datastar v1.0.2 for interactive updates (voting, search, real-time comments) -- **Auth**: argon2id password hashing, JWT tokens, email verification via SMTP +- **Axum** async web server with **Askama** compile-time HTML templates +- **SQLite** via rusqlite — embedded, no separate database service +- **Datastar** v1.0.2 for frontend interactivity (voting, search, real-time comment updates via SSE) +- **Auth**: argon2id password hashing, JWT tokens, email verification via hand-rolled SMTP + +### Database + +- Thread-local read connections (one per blocking thread, zero-contention) +- Single writer connection behind a Mutex (SQLite allows one writer at a time) +- Blocking threads capped to core count via `max_blocking_threads` +- WAL mode, `synchronous=NORMAL`, mmap, prepared statement caching +- BurntSushi-style migrations: numbered `.sql` files in `migrations/`, embedded at compile time via `include_str!` + +### Frontend (Datastar) + +- Use `data-init` (not `data-on:load`) for running expressions on element init +- SSE responses use `text/event-stream` with `datastar-patch-elements` events +- Default outer morph mode — include element ID in response HTML +- `X-Accel-Buffering: no` header on SSE responses for nginx compatibility + +## Performance + +Benchmarked on a 2-core Hetzner VPS (AMD EPYC, 2GB RAM): + +| Endpoint | RPS | Latency | +|----------|-----|---------| +| Homepage | 3,500 | 3ms avg | +| Story page | 12,000 | 8ms avg | +| Static file (ceiling) | 22,000 | 4ms avg | + +On a Ryzen 7 7700 (16 threads): + +| Endpoint | RPS | +|----------|-----| +| Homepage | 31,000 | +| Story page | 95,000 | -## Development +## Setup ```bash +cp .env.example .env # edit with real values nix develop -cargo run # start server on :8000 -cargo watch -x run # auto-reload on changes +cargo run ``` -### Environment variables +## Deployment + +Deployed on NixOS via an infrastructure flake. Push to `simple-web-app`, update the infra flake (`nix flake update simple-web-app`), push infra. -| Variable | Default | Description | -|----------|---------|-------------| -| `PORT` | `8000` | Server port | -| `DB_PATH` | `data/app.db` | SQLite database path | -| `JWT_SECRET` | random | Secret for JWT tokens (random per restart if unset) | -| `SMTP_HOST` | `localhost` | SMTP server for verification emails | -| `SMTP_PORT` | `25` | SMTP port | -| `SMTP_FROM` | `noreply@silasbrack.com` | Sender email address | -| `SITE_URL` | `http://localhost:8000` | Base URL for email links | -| `DEBUG` | `true` | Debug mode | +Static assets (CSS, JS) are embedded in the binary at compile time. Nginx handles gzip compression. ## Migrations -Schema migrations live in `migrations/` as numbered SQL files and are embedded at compile time: +Schema migrations live in `migrations/` as numbered SQL files, embedded at compile time: ``` migrations/ @@ -41,16 +73,4 @@ migrations/ 0002_email_verification.sql ``` -To add a new migration, create `migrations/NNNN_description.sql` and add an `include_str!` entry in `src/migrations.rs`. Migrations run automatically on startup in a single transaction. - -## Building - -```bash -nix build # produces result/bin/simple-web-app -``` - -## Deployment - -Deployed on NixOS via the infrastructure flake. The app runs as a systemd service with `DynamicUser=true`, database on a Hetzner volume at `/mnt/volume-hel1-1/simple-web-app/data/app.db`. - -Static assets (CSS, JS) are embedded in the binary at compile time. +To add a migration, create `migrations/NNNN_description.sql` and add an `include_str!` line in `src/migrations.rs`. Migrations run automatically on startup in a single transaction. diff --git a/src/config.rs b/src/config.rs @@ -11,44 +11,29 @@ pub struct Config { pub site_url: String, } -impl Config { - pub fn from_env() -> Self { - let db_path = env::var("DB_PATH").unwrap_or_else(|_| "data/app.db".to_string()); - - let jwt_secret = env::var("JWT_SECRET").unwrap_or_else(|_| { - // Generate a random secret if not provided - use rand::Rng; - let mut rng = rand::thread_rng(); - let bytes: [u8; 32] = rng.gen(); - bytes.iter().map(|b| format!("{:02x}", b)).collect() - }); - - let debug = env::var("DEBUG") - .map(|v| v.to_lowercase() == "true" || v == "1") - .unwrap_or(true); - - let port = env::var("PORT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(8000); +fn require(name: &str) -> String { + env::var(name).unwrap_or_else(|_| panic!("{name} must be set")) +} - let smtp_host = env::var("SMTP_HOST").unwrap_or_else(|_| "localhost".to_string()); - let smtp_port = env::var("SMTP_PORT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(25); - let smtp_from = env::var("SMTP_FROM").unwrap_or_else(|_| "noreply@silasbrack.com".to_string()); - let site_url = env::var("SITE_URL").unwrap_or_else(|_| "http://localhost:8000".to_string()); +fn require_parse<T: std::str::FromStr>(name: &str) -> T +where + T::Err: std::fmt::Display, +{ + let val = require(name); + val.parse().unwrap_or_else(|e| panic!("{name}={val}: {e}")) +} +impl Config { + pub fn from_env() -> Self { Self { - db_path, - jwt_secret, - debug, - port, - smtp_host, - smtp_port, - smtp_from, - site_url, + db_path: require("DB_PATH"), + jwt_secret: require("JWT_SECRET"), + debug: require_parse("DEBUG"), + port: require_parse("PORT"), + smtp_host: require("SMTP_HOST"), + smtp_port: require_parse("SMTP_PORT"), + smtp_from: require("SMTP_FROM"), + site_url: require("SITE_URL"), } } }