Bendik Aagaard LynghaugandClaude Fable 5.1 e4c0094f34 Fix release build: box the call tiles, icons and in-call subtree
`cargo leptos build --release` died with "queries overflow the depth
limit": every tile's Show/icon views inlined into one enormous
hydrate_async type for the whole call subtree. VideoTile, CallIcon and
CallActive now return AnyView, the same cut CallPanel got in 0.2.1. The
dev profile never tripped it, which is why 0.3.0 shipped broken.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3sD29ozDvqA7jozeZXZoB
2026-10-02 18:33:23 +02:00
2026-10-02 18:16:33 +02:00
2026-10-02 18:16:33 +02:00
2026-10-02 18:16:33 +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)
  call.rs          call signaling types + send_signal server fn (call.room.*, never archived)
  webrtc.rs        browser-only mesh WebRTC: peers, mic/cam toggles, screen share
  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

Calls

The lobby room has a mesh WebRTC call (every pair of browsers connects directly; fine for a handful of people, not more). Signaling rides NATS on call.room.<room> via /call-sse/<room> and the send_signal server fn, so it is never archived. Only a public STUN server is configured: calls across symmetric NATs will not connect without a TURN relay, which this app does not run.

In a call:

  • MIC / CAM toggle your mic and camera (the track stays attached and sends silence/black, so toggling is instant and needs no renegotiation).
  • SCR ▶ share shares a screen, window, or tab. It swaps the outgoing video track on every peer with replaceTrack; your camera comes back when you stop, or when the browser's own "Stop sharing" bar is used.
  • Tiles show the participant's name and MIC ✕ / CAM ✕ / SCR badges, kept in sync by a Status signal on the same subject.
  • Click a tile to pin it as the stage (a shared screen pins itself); double-click for fullscreen.
  • A peer whose tab closes without leaving is dropped once their connection fails, and ICE candidates that arrive before the offer are buffered rather than lost.

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
149 KiB
v0.3.2
Latest
2026-10-03 08:17:22 +00:00
Languages
Rust 81.8%
CSS 16.3%
Shell 1.2%
Dockerfile 0.7%