xevion.dev

A dual-process portfolio website built with Rust and SvelteKit

Web App Active Updated 2h ago

This site runs in a single container — no CDN, no managed platform doing the heavy lifting out front. Everything a hosted setup would normally hand you for free — caching, compression, static-asset serving, server-side rendering, and turning away junk traffic — has to live in the application itself.

So it runs as two cooperating runtimes. A Rust service (axum) is the only thing exposed to the network; behind it, a Bun process running SvelteKit renders pages. Rust is the front door for every request; Bun does the one job Rust has no business doing itself — and the interesting decisions all live at the seam between them.

Two cooperating runtimes

Rust owns almost everything: it routes requests, serves the JSON API straight from Postgres, serves the embedded frontend assets, validates sessions, caches rendered pages, and runs the background jobs. Bun owns exactly one thing — server-rendering SvelteKit — and to get the data it needs, it calls back into Rust's own API.

In production the two talk over a Unix socket; in development, over loopback HTTP, which keeps hot-reload and debugging simple. The same client code drives both, so nothing downstream has to know which transport it got.

bash
# production: a TCP port plus a Unix socket, rendering over Bun's socket
xevion-server --listen :8080 --listen /tmp/api.sock --downstream /tmp/bun.sock

# development: plain loopback HTTP on both sides
xevion-server --listen :10237 --downstream http://localhost:10238

A page request flows in one direction and back: Rust receives it, checks its in-memory cache, and on a miss proxies it to Bun. Bun renders the page — calling back to Rust's API for projects, tags, and settings — and returns HTML, which Rust caches and serves. The types Bun consumes aren't hand-written on the TypeScript side; they're generated from the Rust structs with ts-rs, so the contract across the language boundary can't quietly drift out of sync.

A single self-contained binary

The Rust binary carries the entire frontend inside it. At compile time, include_dir! embeds the built SvelteKit client and the prerendered error pages straight into the executable. There's no static directory to deploy alongside it and no filesystem reads on the asset path — the bytes are already in the binary.

Compression happens once, at build time: the static assets are pre-compressed and the compressed copies are embedded too, so Rust answers a request by handing over bytes it already has rather than compressing on the fly. Content-addressed assets get a one-year immutable cache header, since their filenames change whenever their contents do.

In-process page cache

Rendered pages are cached in the process itself — no Redis, no CDN edge — using an ISR cache built on moka. A freshly cached page is served as-is for a short window; once it ages past that, the cache keeps serving the stale copy instantly while a background task re-renders it. A reader never waits on a render they didn't cause.

Authenticated requests skip the cache entirely. Admin pages are session-specific, so anything carrying a valid session cookie is proxied straight through and never stored.

The trust boundary

Bun needs to know whether a request belongs to a signed-in admin, but it never checks a session itself. It reads one header — X-Session-User — and trusts it completely. That is only safe because of what Rust does on the way in.

Browser-based CLI login

The same crate compiles a second binary, xevion — a content client for the live API. The interesting part is how it signs in: there are no API keys to paste or store. xevion login runs the device-authorization flow, the same handshake a smart TV uses to log you into an account.

bash
xevion targets add production --url https://xevion.dev
xevion login --api production
# opens an approval page; approve it while signed into the
# admin UI, and the token lands in your config

The CLI opens the approval page and waits. You confirm it's really you from a browser that's already authenticated, and the moment you approve, the token is handed back to the waiting process. The server keeps only a hash of it, never the token itself — so a database dump yields nothing usable.

This page was written through that CLI. A project's detail body is a TipTap document stored as JSON; the content commands edit it block by block, every write is validated against a strict node-and-mark allow-list, and Bun renders it to sanitized HTML — syntax highlighting included — at request time. The words you're reading made the same trip through the socket as everything else.

The tarpit

A public site is probed constantly for things it doesn't have — /wp-login.php, /.env, /.git/config, phpMyAdmin, exposed actuator endpoints. A clean 404 is the polite reply. This site does something else: it routes those requests into a tarpit, an HTTP response engineered never to finish.

How the tarpit works

A flagged request gets one of three randomly chosen fake bodies: a stream of random bytes, a fake admin panel stuck perpetually "Loading…", or a fake JSON list of user records. Whichever it is, the response dribbles out in small chunks with a random pause between each, and the final chunk never comes. A naive scanner just waits.

The trick can't be turned back on the server. Tarpit connections are bounded by semaphores — at most 1000 at once, and 100 per IP — and a prober past the limit gets an immediate, cheap 503 instead. The point is to waste the scanner's time, not the server's.

Tradeoffs

The whole design assumes a single instance. The page cache and the session table both live in process memory, so a second replica would share neither — scaling horizontally would mean moving both to something external. For a personal site on one container, in-memory is faster and simpler: a restart just reloads sessions from Postgres and rewarms the cache on demand. But the ceiling is real.

Running two language runtimes in one container costs operational complexity a single-stack app wouldn't carry. There are two processes to supervise, a socket that must exist before Rust will accept traffic, and a health check that has to confirm both halves are alive. For one container that's a fair price; it wouldn't be for anything that had to scale out.

§08

Gallery

On this page