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

bash
git clone https://github.com/bobowski/stario.git
cd stario/examples/chat-room
uv sync
uv run stario watch app.main:bootstrap

If 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:

bash
uv run pytest

2. What this example adds

ConcernTiles (main.py)Chat room (app/)
LayoutSingle fileFeatures under app/features/
StateIn-memory GameSQLite via app/db.py
ConfigConstants in fileConfig.from_env() in app/config.py
URLsModule-level UrlPathPer-feature urls.py
TestsNone in exampletests/test_lobby.py, tests/test_room.py, tests/test_assets.py
text
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:

  1. Read config once (Config.from_env()).

  2. Open shared dependencies (Database, Relay).

  3. Register static assets (StaticAssets(ASSETS).register(app)).

  4. Call each feature's register_* function.

python
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)
    yield

Nothing 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:

FileRole
urls.pyUrlPath constants shared by handlers and views
models.pyDomain dataclasses (Room, Message, User)
data.pySCHEMA DDL + query functions taking Database
subjects.pyRelay subject helpers (room.{id}.*, presence, message, …)
signals.pyTyped signal reads for Datastar POST bodies
views.pyPure HTML trees (common.shell.page wraps layout)
handlers.pyHandler 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

RouteJob
GET /Lobby — list rooms and online counts
POST /roomsCreate room from dialog signals
DELETE /rooms/{id}Delete room and related rows
GET /rooms/{id}First paint; mint demo user identity
GET /rooms/{id}/subscribeLong-lived SSE; patch HTML on relay events
POST /rooms/{id}/sendStore message — 204, update via SSE
POST /rooms/{id}/typingTyping 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

VariableDefaultMeaning
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).