Skip to content
This repository was archived by the owner on Aug 17, 2026. It is now read-only.

Latest commit

 

History

15 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Rustic Chat

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.

About this project (a WASM experiment, not a product)

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 under cargo test on 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 match force 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 + the wasm32 target); the initial download is larger than hand-written JS; and reactive-ownership and streaming have real gotchas (see docs/DEBUGGING.md). These are the costs the experiment surfaced.

When this approach is actually a good fit

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.rs has 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.

Development

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 serve

Open http://127.0.0.1:8080. OpenRouter supports localhost OAuth callbacks on arbitrary ports.

Production build

trunk build --release

Deploy 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.

Docker deployment

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 --build

The 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.

License

Released under the MIT License.

About

🧪 A serverless OpenRouter chat client built with Rust, Leptos, and WebAssembly — the browser talks to OpenRouter directly via OAuth PKCE, no backend required. An honest WASM experiment, not a production product.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages