The SSR branch rendered an empty call-panel div while the hydrate branch expected a child button node. On any full load/refresh of the lobby room, the mismatched hydration cursor hit tachys's unreachable!() panic path, trapping the wasm instance and killing all reactivity (routing, room switching, SSE) for the rest of the page load, while the already-rendered SSR HTML stayed visually intact. SSR now renders the same default join-button markup hydrate expects.
cnats — chat over the bus
A realtime web chat built with Leptos (SSR + hydration on Axum) where every room is a NATS subject and sign-in is Kanidm SSO (OIDC authorization-code + PKCE).
browser ──(server fn POST)──▶ axum ──publish──▶ NATS chat.room.<room>
browser ◀──(SSE /sse/<room>)── axum ◀─subscribe── NATS chat.room.<room>
browser ◀──(302 /auth/*)────── axum ◀──OIDC──▶ Kanidm
axum ◀─JetStream (CHAT / cnats-postgres)─ NATS
└──INSERT──▶ Postgres (history archive)
The browser never talks to NATS directly: the server publishes on behalf of the
signed-in session (sender identity comes from the session, never the client)
and fans messages out to browsers over Server-Sent Events. Any other NATS
client on the bus can publish/subscribe to chat.room.* and participate.
Install
- Arch (AUR):
yay -S cnats— installs the binary,cnats.service, and config at/etc/cnats/env; thensystemctl enable --now cnats. - Docker:
docker run --env-file .env -p 3000:3000 bendik/cnats(or the compose file below).
Releases are cut by the Gitea workflow in .gitea/workflows/release.yml: a
v* tag builds x86_64 + aarch64 tarballs, publishes them as release assets,
pushes the image to Docker Hub, and updates the AUR package.
Prerequisites
- Rust (stable) with the
wasm32-unknown-unknowntarget cargo-leptos:cargo install cargo-leptos --locked- A NATS server:
docker compose up -d nats(ornats-serverlocally) - A running Kanidm instance you administer
Kanidm setup
Register the app as an OAuth2 client (confidential, with PKCE — Kanidm's default). Replace the URLs with yours:
kanidm login --name idm_admin
# create the client
kanidm system oauth2 create cnats "cnats chat" http://localhost:3000
# register the redirect URL used by this app
kanidm system oauth2 add-redirect-url cnats http://localhost:3000/auth/callback
# map which Kanidm groups may sign in, and grant the scopes the app requests
kanidm group create cnats_users
kanidm group add-members cnats_users your_username
kanidm system oauth2 update-scope-map cnats cnats_users openid profile email
# if you serve the app over plain http in dev, allow insecure redirect urls
kanidm system oauth2 enable-localhost-redirects cnats
# read back the client secret for .env
kanidm system oauth2 show-basic-secret cnats
Kanidm's OIDC discovery endpoint is per-client:
https://<kanidm>/oauth2/openid/<client_id>/.well-known/openid-configuration. The app derives this fromKANIDM_URL+OAUTH2_CLIENT_IDautomatically.
Configuration
cp .env.example .env # then edit
| Variable | Meaning |
|---|---|
NATS_URL |
NATS server, default nats://127.0.0.1:4222 |
DATABASE_URL |
Postgres for message history, default postgres://cnats:cnats@127.0.0.1:5432/cnats |
KANIDM_URL |
Base URL of Kanidm, e.g. https://idm.example.com |
OAUTH2_CLIENT_ID |
The Kanidm oauth2 client name (cnats above) |
OAUTH2_CLIENT_SECRET |
Output of show-basic-secret |
PUBLIC_URL |
Where browsers reach this app; ${PUBLIC_URL}/auth/callback must be a registered redirect URL |
COOKIE_SECURE |
true behind HTTPS (production) |
Run
docker compose up -d nats postgres
cargo leptos watch # dev, auto-reload on http://127.0.0.1:3000
cargo leptos build --release # production build (binary + target/site)
Or run the whole stack (app image included) with docker compose up.
Open two browser windows, sign in, and chat — or join from the CLI:
nats sub 'chat.room.*'
nats pub chat.room.lobby '{"id":"cli-1","room":"lobby","username":"cli","display_name":"CLI","text":"hello from the bus","time":"12:00:00","ts":0}'
CLI-published messages are archived too — the JetStream consumer sees
everything on chat.room.*, not just what this app publishes.
Layout
src/
main.rs axum entrypoint: routes, sessions, NATS + OIDC bootstrap
lib.rs hydrate entrypoint (wasm)
app.rs Leptos UI (login gate + chat console)
auth.rs shared User type + current_user server fn
chat.rs shared ChatMessage/rooms + send_message server fn (publishes to NATS)
server/
oidc.rs Kanidm OIDC login/callback/logout handlers
sse.rs NATS → browser SSE bridge (one subscription per client)
store.rs JetStream → Postgres archive + room history queries
style/main.css the console theme
Notes & production hardening
- Sessions are in-memory (
tower-sessionsMemoryStore): restart logs everyone out, and it is single-instance. Swap in a Redis/SQL store for multiple replicas. - History: a durable JetStream pull consumer (
CHATstream,cnats-postgresconsumer) archiveschat.room.*into Postgres; joining a room backfills the last 100 messages. Inserts are idempotent on message id (JetStream is at-least-once). TheCHATstream keeps default limits — cap it withnats stream edit CHAT --max-age=…if the bus is chatty, since Postgres already holds the archive. - The OIDC client verifies ID-token signature, nonce, and CSRF state, and requires PKCE — but there is no token refresh; the app session lives independently of the Kanidm token lifetime.