Chat room
Multi-room chat with SQLite persistence — the larger-app step after Realtime tiles. The example lives at examples/chat-room in the stario repo.
You should already be comfortable with Hello world (signals + SSE(w).patch_signals) and Realtime tiles (Relay, c.alive, CQRS-shaped routes). This tutorial focuses on how a multi-file Stario app is organized, not on re-teaching Datastar basics.
1. Run it
git clone https://github.com/bobowski/stario.gitcd stario/examples/chat-roomuv syncuv run stario watch app.main:bootstrapIf you are in the Wratilabs monorepo, cd projects/stario/examples/chat-room instead.
Open http://127.0.0.1:8000 (set STARIO_PORT if the default is busy).
Run tests the same way the example does:
uv run pytest2. What this example adds
| Concern | Tiles (main.py) | Chat room (app/) |
|---|---|---|
| Layout | Single file | Features under app/features/ |
| State | In-memory Game | SQLite via app/db.py |
| Config | Constants in file | Config.from_env() in app/config.py |
| URLs | Module-level UrlPath | Per-feature urls.py |
| Tests | None in example | tests/test_lobby.py, tests/test_room.py, tests/test_assets.py |
app/ main.py bootstrap config.py Config.from_env() assets.py fingerprinted static URLs db.py SQLite core common/ shared page shell features/ lobby/ room list UI room/ room domain + chat + SSE static/tests/Full file-by-file roles: Structuring larger applications.
The HTTP pattern is the same CQRS shape as tiles: GET paints HTML once, GET /subscribe stays open with SSE(w).patch_elements, and POST commands mutate state then fan out through Relay (chat send/typing return 204; lobby create/delete redirect).
3. Start at bootstrap
Open app/main.py. The composition root does four jobs:
Read config once (
Config.from_env()).Open shared dependencies (
Database,Relay).Register static assets (
StaticAssets(ASSETS).register(app)).Call each feature's
register_*function.
async def bootstrap(app: App, span: Span): config = Config.from_env() db = Database(config.db_path) db.apply_schema(room_data.SCHEMA) relay = Relay() # span.attrs(...) and span.step("static_assets") annotate startup telemetry static = StaticAssets(ASSETS) static.register(app) register_lobby(app, db, relay) register_room(app, db, relay) yieldNothing in app/features/ imports bootstrap — features stay importable without side effects. See Structuring larger applications.
4. Feature module shape
Each feature under app/features/ can use the same optional files:
| File | Role |
|---|---|
urls.py | UrlPath constants shared by handlers and views |
models.py | Domain dataclasses (Room, Message, User) |
data.py | SCHEMA DDL + query functions taking Database |
subjects.py | Relay subject helpers (room.{id}.*, presence, message, …) |
signals.py | Typed signal reads for Datastar POST bodies |
views.py | Pure HTML trees (common.shell.page wraps layout) |
handlers.py | Handler factories + register_room(app, …) at the bottom |
The lobby is UI over the room domain — it omits models.py and data.py and imports room.data and room.urls instead. Domain imports flow one way: lobby → room, never the reverse.
5. Routes and jobs
| Route | Job |
|---|---|
GET / | Lobby — list rooms and online counts |
POST /rooms | Create room from dialog signals |
DELETE /rooms/{id} | Delete room and related rows |
GET /rooms/{id} | First paint; mint demo user identity |
GET /rooms/{id}/subscribe | Long-lived SSE; patch HTML on relay events |
POST /rooms/{id}/send | Store message — 204, update via SSE |
POST /rooms/{id}/typing | Typing flag — 204, update via SSE |
Commands read signals via each feature’s read_*_signals helper (read_lobby_signals, read_chat_signals) and validate in plain Python — see No validation layer in the framework.
Subscribe handlers open async with relay.subscribe(room.{id}.*) as live: and loop with async for subject, _ in c.alive(live): so disconnect and shutdown end the stream cleanly (Request — alive(), Toolbox — Relay).
When a room is deleted while tabs are still subscribed, the handler calls SSE(w).navigate(LOBBY.href()) so live clients return to the lobby without a full navigation you authored in the browser.
6. Static assets and URLs
app/assets.py builds an AssetManifest at import time; views call ASSETS.href("css/style.css") and load vendored Datastar from static/js/datastar.js. No url_for — logical paths fingerprint once at scan time (Static assets and fingerprinting).
Room URLs are constants in app/features/room/urls.py — handlers register app.get(ROOM, …) and views build links with SEND.href(room_id=room.id).
7. Configuration
| Variable | Default | Meaning |
|---|---|---|
CHAT_DB_PATH | :memory: | SQLite path; in-memory for zero-setup dev |
Stario does not load dotenv files — export variables in your shell or process manager. Server listen address and tracers use STARIO_* (Configuration).
8. Tests
Tests use async with TestClient(app.main.bootstrap) — the same bootstrap the CLI loads. Each feature has its own test module asserting HTTP status, HTML fragments, and relay-driven updates (Testing with TestClient).
9. Read next
Structuring larger applications — conventions this example follows.
Injecting dependencies — passing
dbandrelayinto handler factories.The go-to architecture — command vs query lanes at the design level.
Getting insights from SQLite tracer — if you add
STARIO_TRACER=sqlitewhile developing.