A static OpenRouter chat client built with Rust, Leptos, and WebAssembly. The browser connects to OpenRouter directly using OAuth PKCE; there is no backend.
This is a research project, not a product. It exists to explore what a non-trivial browser app written entirely in Rust/WASM can do and how it feels to build — not to be deployed and relied on. See About this project.
This is a research project: an honest attempt to build a non-trivial browser app entirely in Rust/WASM and see what it can do and how it feels to work in. It is not a product, and it is not something you should deploy and rely on. If you want a chat client to actually use, reach for a maintained one. What's here is a worked example you can read, run, and learn from.
First, an honesty check, because it's the most common misconception: WASM does
not unlock any browser capability that plain JavaScript lacks. IndexedDB,
fetch with SSE streaming, AbortController, sessionStorage — all reachable
from JS, and this app only touches them by calling back out through JS bindings
(web-sys / wasm-bindgen) anyway. Those APIs come from running in the browser,
not from WASM. So WASM is a language choice — a way to run Rust in the browser
— not a capability unlock. This project exists to explore that choice, not to
argue it's the right one for production.
What the experiment set out to feel out:
-
How far does one language, one tested core actually go? The domain model and its pure functions (
domain.rs) are ordinary Rust with no browser dependencies, so tree reconstruction, sibling/branch logic, and request building run undercargo teston the native host — no browser or JS test runner needed — and the same code compiles to WASM for the UI. That's the part that felt genuinely nice. -
Does the type system pull its weight on the fiddly parts? Streaming concurrency, abort handling, and a persisted message graph (parent-linked, validated for cycles and dangling parents on import) are where dynamically typed UIs tend to accumulate silent bugs. Rust's enums and exhaustive
matchforce every state of the message lifecycle (Streaming → Complete | Stopped | Error) to be handled. This is where the approach earns its keep. -
Where does it fight you? Every DOM and browser-API call crosses a JS-interop boundary; the build needs an extra toolchain (
trunk+ thewasm32target); the initial download is larger than hand-written JS; and reactive-ownership and streaming have real gotchas (seedocs/DEBUGGING.md). These are the costs the experiment surfaced.
Setting the "it's an experiment" framing aside, the trade-offs above point to a real profile. Reaching for Rust/WASM the way this app does makes sense when several of these hold — and the more of them, the stronger the case:
-
The hard part is logic, not the DOM. The app's difficulty lives in a branching message tree, a persisted graph with validation, and streaming state machines — not in lots of bespoke widgets. WASM shines when the value is in a testable core; it's a poorer trade when the app is mostly DOM plumbing, which is exactly where the JS-interop tax hurts most.
-
You want a core that runs off the browser. Because
domain.rshas no browser dependencies, the same logic is unit-tested natively and could be reused in a CLI, a server, or a native app. If the logic will only ever live in one browser tab, that portability is unrealized and the case weakens. -
Correctness matters more than a few KB of download. For a durable local store, resumable streams, and an import validator, catching whole classes of bug at compile time is worth a larger bundle. For a small, mostly-static, or ship-today-at-any-cost page, that ordering flips.
-
The team already knows Rust. The payoff is Rust's type system and tooling; the cost is a compile toolchain and an interop boundary. If Rust is already in the stack that's a bargain — if it means learning Rust and WASM and Leptos just to render a chat box, the honest answer is usually "use TypeScript."
-
Serverless is a feature, not a constraint. Compiling to a
dist/of static assets you can host anywhere — no runtime, no backend, all state in the browser — is a genuine deployment win when you want zero server-side state.
The takeaway isn't "build your chat client this way." It's a concrete, readable
data point on what a compiled, statically typed core buys you in the browser and
what it costs — served, conveniently, as plain static files, since Rust compiles
to a dist/ directory you can host anywhere.
Rustic Chat pins hra42/openrouter-rust to the exact v0.2.0 release commit
5109cea4f8d06be21c2ffe699312ae6aad56e399 with its browser feature enabled.
rustup target add wasm32-unknown-unknown
cargo install trunk --locked
trunk serveOpen http://127.0.0.1:8080. OpenRouter supports localhost OAuth callbacks on arbitrary ports.
trunk build --releaseDeploy the generated dist/ directory to any HTTPS static host. The OAuth
callback is calculated from the current origin and path, including subpath
deployments.
The OpenRouter API key is stored only in sessionStorage (tab-scoped, never
sent to any server other than OpenRouter). Conversations, preferences, and the
cached model catalog are persisted in IndexedDB. An older flat localStorage
chat (rustic-chat.v1) is migrated into the tree model automatically on first
run.
The production container builds the Rust/WASM application with Trunk, then copies only the generated static files into an unprivileged Nginx image.
docker compose up -d --buildThe container listens on port 8080 and exposes /healthz for health checks.
The included Compose file connects it to the external traefik network and
publishes it as https://rustic.hra42.com on the worker host.
Pushes to main deploy automatically through GitHub Actions. The workflow
connects to the worker over SSH, updates /opt/rustic-chat to origin/main,
builds the image on the worker, starts the Compose service, and waits for its
health check. It does not build or publish preview images.
Released under the MIT License.