bl 9326f93576 Fix hydration mismatch in CallPanel that crashed room switching
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.
2026-07-29 10:50:02 +02:00
2026-07-28 12:03:23 +02:00
2026-07-28 12:03:23 +02:00
2026-07-28 12:03:23 +02:00

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; then systemctl 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-unknown target
  • cargo-leptos: cargo install cargo-leptos --locked
  • A NATS server: docker compose up -d nats (or nats-server locally)
  • 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 from KANIDM_URL + OAUTH2_CLIENT_ID automatically.

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-sessions MemoryStore): restart logs everyone out, and it is single-instance. Swap in a Redis/SQL store for multiple replicas.
  • History: a durable JetStream pull consumer (CHAT stream, cnats-postgres consumer) archives chat.room.* into Postgres; joining a room backfills the last 100 messages. Inserts are idempotent on message id (JetStream is at-least-once). The CHAT stream keeps default limits — cap it with nats 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.
S
Description
No description provided
Readme MIT 584 KiB
v0.2.5 Latest
2026-08-16 14:42:39 +02:00
Languages
Rust 80.3%
CSS 17.2%
Shell 1.6%
Dockerfile 0.9%