# unibus playground An all-in-one, web-based sandbox for the **unibus** message bus. One command brings up the entire stack embedded — no NATS to install, no services to wire — and a browser UI lets you exercise the bus visually: create peers, create and join rooms (cleartext or end-to-end encrypted), invite, publish, watch messages arrive live, and kick members (forward secrecy). This is a **playground** (see `.claude/rules/playgrounds.md`): it lives inside the `unibus` app, reuses the parent Go module (no separate `go.mod`), is not indexed, and keeps all runtime state under `playground/local_files/` (ephemeral, safe to delete). ## Run From the `unibus` app directory: ```bash cd /home/enmanuel/fn_registry/projects/message_bus/apps/unibus go run ./playground ``` Then open **http://localhost:7700** in your browser. Stop with `Ctrl-C` — the server tears down the web UI, every bus client, the control plane, and the embedded NATS cleanly (no orphaned processes). ## Architecture The browser never speaks NATS. The Go server is the actual bus peer: ``` browser ──fetch/SSE──▶ playground server (:7700) │ holds one unibus client per named peer ├──HTTP──▶ membership control plane (127.0.0.1:8480) └──NATS──▶ embedded NATS + JetStream (:4260) ``` - **:7700** — web UI (the only browser-facing port). - **127.0.0.1:8480** — membership control plane (rooms, members, sealed keys, rekey, blobs). Internal only. - **:4260** — embedded NATS + JetStream (the data plane). Internal only. Each named peer gets its own long-term identity, persisted to `playground/local_files/.id`, so a peer keeps the same endpoint across restarts. When a peer creates or joins a room, the server subscribes on its behalf and streams every received frame to that peer's open browser tabs over Server-Sent Events. The playground only orchestrates the public unibus client API (`CreateRoom`, `Join`, `Subscribe`, `Publish`, `Invite`, `Kick`); it never reimplements bus or crypto logic. ## Try it: 2 peers + encryption + kick 1. Open **two browser tabs** on http://localhost:7700. 2. Tab A: type `alice`, click **Connect**. 3. Tab B: type `bob`, click **Connect**. 4. Tab A (alice): type a subject like `room.general`, tick **🔒 encrypted (E2E)**, click **Create room**. Copy the resulting `room_id`. 5. Tab A (alice): in the Action panel, pick `bob` as the target peer (use the ↻ button to refresh the peer list if needed) and click **Invite to this room**. 6. Tab B (bob): paste the `room_id` into the join field and click **Join**. 7. Type messages in **both** tabs and hit Send — each message appears live in both tabs, tagged with subject, sender, time, and 🔒 (encrypted) or `clear`. 8. Tab A (alice): click **Kick from this room** with `bob` selected. The room key rotates to a new epoch. New messages alice sends are no longer visible to bob — **forward secrecy**: bob no longer holds the current key. Cleartext rooms (leave the checkbox unticked) behave like plain NATS fan-out: fast, ephemeral, unsigned. Encrypted rooms are the Matrix-like mode: E2E encrypted, persisted, and per-message signed. ## Benchmark: throughput simulator The bottom panel of the UI is a performance simulator. Press **▶ Ejecutar benchmark** and one publisher floods a fresh room with thousands of messages that N subscribers receive (fan-out); a live canvas chart animates the sent vs received totals while it runs. The two policy axes are exposed as **independent flags**, so the benchmark measures the cost of each layer in isolation: | JetStream | Encryption | Room policy | What it costs | |---|---|---|---| | off | off | `{Encrypt:false, Persist:false}` | plain core NATS fan-out | | **on** | off | `{Encrypt:false, Persist:true}` | durable JetStream (publish ack per message) | | off | **on** | `{Encrypt:true, Persist:false}` | AEAD + Ed25519 signature per message, core transport | | **on** | **on** | `{Encrypt:true, Persist:true}` | full E2E + durable history | A **payload size** slider (16 B – 8 KiB) sets the message size. Encrypted or persistent runs are capped to 30 000 messages (each message pays per-message crypto and/or a JetStream ack, so they run much slower than plain NATS). The benchmark uses its own ephemeral peers (fresh identities, never persisted), so it never touches the named peers of the manual sandbox. It is driven by an SSE endpoint that streams progress samples: ```bash curl -N "http://localhost:7700/api/bench?n_msgs=20000&n_subs=3&payload=128&encrypt=0&persist=0" # emits: data: {"type":"start",...} data: {"type":"sample",...} data: {"type":"done",...} ``` Query params: `n_msgs`, `n_subs` (1–16), `payload` (bytes), `encrypt` (0/1), `persist` (0/1). ## State / cleanup All writable state lives under `playground/local_files/`: - `.id` — per-peer identity (private keys; treat like an SSH key). - `play.db` — membership store (rooms, members, sealed keys). - `blobs/` — media blob store. - `js/` — embedded JetStream store. Delete the whole `playground/local_files/` directory to reset to a clean slate. It is gitignored and never distributed.