Skip to content

Commit 9cab6d7

Browse files
apiadclaude
andcommitted
feat(examples): canonical 05_realtime — chat room with presence
Tier 5 of the canonical examples set: a multi-user chat room over WebSocket. Exercises every realtime wire path — @app.server.on ("connect")/("disconnect") lifecycle, @app.server.realtime endpoints, @app.client.realtime handlers with both .broadcast(...) (fan-out) and .invoke(client_id, ...) (targeted, for the initial history push), and @app.client.on("connect") to fire the history request as soon as the socket opens. Smoke test runs two concurrent websocket_connect sessions: alice sees bob's join broadcast, alice's post_message reaches bob, bob's request_history triggers a receive_history.invoke envelope back only to bob. E2e drives a real browser through the full loop: hydrate, receive the server's connect broadcast, send a message, see it round- trip through the WS back into the DOM. Surfaced gap 7.8 — `get_client_id()` returns a `Thing` proxy on second call (after session storage is populated) rather than a bare str, which breaks `json.dumps` inside `_call_realtime`. Workaround applied in the example (`str(get_client_id())`); fix proposal documented in issues/7. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e8fff04 commit 9cab6d7

4 files changed

Lines changed: 507 additions & 0 deletions

File tree

examples/05_realtime.py

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
"""Tier 5 canonical example — a multi-user chat room with live presence.
2+
3+
WebSocket + Pyodide bundle. Demonstrates the full realtime API:
4+
`@app.server.on("connect")` / `("disconnect")` lifecycle handlers,
5+
`@app.server.realtime` server-side fire-and-forget endpoints,
6+
`@app.client.realtime` server-pushable client functions (both the
7+
`.broadcast(...)` fan-out path and the `.invoke(client_id, ...)` targeted
8+
path for the initial history push), and `@app.client.on("connect")` to
9+
trigger the history request as soon as the WS opens.
10+
11+
Run:
12+
13+
python examples/05_realtime.py
14+
15+
Then open http://localhost:8000 in two browser tabs. Each tab gets a
16+
random `anon-XXXXXX` name and is shown in the sidebar of the other.
17+
Type a message and hit Send — it appears in both tabs. Change your
18+
display name and the sidebar updates everywhere. Messages and the
19+
user list reset when the server restarts (intentional simplicity —
20+
this example demonstrates the wire protocol, not durable storage).
21+
"""
22+
23+
from violetear import App, Document, StyleSheet
24+
from violetear.color import Colors
25+
from violetear.dom import DOM, Event
26+
from violetear.units import px, rem
27+
28+
29+
app = App(title="Chat Room")
30+
31+
32+
# ---------------------------------------------------------------------------
33+
# Server-side shared state
34+
# ---------------------------------------------------------------------------
35+
#
36+
# In-memory only — every restart wipes the room. A real chat app would
37+
# put this behind a database (see @app.shared once it ships — roadmap
38+
# Phase 4 — for the framework-managed-distribution version).
39+
40+
messages: list[dict] = [] # {from_id, from_name, text}
41+
users: dict[str, str] = {} # client_id -> display_name
42+
43+
44+
def _system_message(text: str) -> dict:
45+
return {"from_id": "system", "from_name": "system", "text": text}
46+
47+
48+
# ---------------------------------------------------------------------------
49+
# Server-side: lifecycle handlers + realtime endpoints
50+
# ---------------------------------------------------------------------------
51+
52+
53+
@app.server.on("connect")
54+
async def on_join(client_id: str):
55+
# Assign a default name. The client's first action after socket-open
56+
# is to call request_history, which will push the full state back.
57+
default_name = f"anon-{client_id[:6]}"
58+
users[client_id] = default_name
59+
msg = _system_message(f"{default_name} joined")
60+
messages.append(msg)
61+
await receive_message.broadcast(msg=msg)
62+
await set_user_list.broadcast(users=users)
63+
64+
65+
@app.server.on("disconnect")
66+
async def on_leave(client_id: str):
67+
name = users.pop(client_id, None)
68+
if name is None:
69+
return
70+
msg = _system_message(f"{name} left")
71+
messages.append(msg)
72+
await receive_message.broadcast(msg=msg)
73+
await set_user_list.broadcast(users=users)
74+
75+
76+
@app.server.realtime
77+
async def post_message(client_id: str, text: str):
78+
text = text.strip()
79+
if not text:
80+
return
81+
name = users.get(client_id, "unknown")
82+
msg = {"from_id": client_id, "from_name": name, "text": text}
83+
messages.append(msg)
84+
await receive_message.broadcast(msg=msg)
85+
86+
87+
@app.server.realtime
88+
async def set_name(client_id: str, new_name: str):
89+
new_name = new_name.strip()
90+
if not new_name or client_id not in users:
91+
return
92+
users[client_id] = new_name
93+
await set_user_list.broadcast(users=users)
94+
95+
96+
@app.server.realtime
97+
async def request_history(client_id: str):
98+
# Targeted invoke — only the requesting client gets the initial state.
99+
# Different code path from the broadcast above (one connection, not all).
100+
await receive_history.invoke(client_id, messages=messages, users=users)
101+
102+
103+
# ---------------------------------------------------------------------------
104+
# Client-side: realtime handlers + DOM mutation
105+
# ---------------------------------------------------------------------------
106+
107+
108+
@app.client.realtime
109+
async def receive_message(msg: dict):
110+
container = DOM.find("chat-log")
111+
row = DOM.create("div").add("chat-row")
112+
if msg["from_name"] == "system":
113+
row.add("chat-row-system")
114+
name_el = DOM.create("span").add("chat-from")
115+
name_el.text = msg["from_name"] + ":"
116+
text_el = DOM.create("span").add("chat-text")
117+
text_el.text = msg["text"]
118+
row.append(name_el)
119+
row.append(text_el)
120+
container.append(row)
121+
122+
123+
@app.client.realtime
124+
async def set_user_list(users: dict):
125+
container = DOM.find("user-list")
126+
container.html("")
127+
for _client_id, name in users.items():
128+
row = DOM.create("div").add("user-row")
129+
row.text = name
130+
container.append(row)
131+
132+
133+
@app.client.realtime
134+
async def receive_history(messages: list, users: dict):
135+
# Initial state push from request_history (targeted invoke).
136+
log = DOM.find("chat-log")
137+
log.html("")
138+
for msg in messages:
139+
row = DOM.create("div").add("chat-row")
140+
if msg["from_name"] == "system":
141+
row.add("chat-row-system")
142+
name_el = DOM.create("span").add("chat-from")
143+
name_el.text = msg["from_name"] + ":"
144+
text_el = DOM.create("span").add("chat-text")
145+
text_el.text = msg["text"]
146+
row.append(name_el)
147+
row.append(text_el)
148+
log.append(row)
149+
user_container = DOM.find("user-list")
150+
user_container.html("")
151+
for _cid, name in users.items():
152+
row = DOM.create("div").add("user-row")
153+
row.text = name
154+
user_container.append(row)
155+
156+
157+
@app.client.on("connect")
158+
async def on_socket_connect():
159+
# WS is now live — ask the server to push our initial state. The server
160+
# responds via receive_history.invoke(client_id=...) so only this client
161+
# gets it (proves the targeted-invoke wire path).
162+
#
163+
# `str(get_client_id())` because on second call `get_client_id()` returns
164+
# a `Thing` proxy (read through session storage) rather than a bare str,
165+
# and `_call_realtime` json.dumps the kwargs — Thing isn't serializable
166+
# (see issues/7.8). str() coerces via Thing.__repr__ → the wrapped value.
167+
from violetear.client import get_client_id
168+
169+
await request_history(client_id=str(get_client_id()))
170+
171+
172+
@app.client.on("ready")
173+
async def on_ready():
174+
# Surface the default name in the rename field so the user can edit it.
175+
from violetear.client import get_client_id
176+
177+
my_id = str(get_client_id())
178+
name_input = DOM.find("name-input")
179+
name_input.value = f"anon-{my_id[:6]}"
180+
181+
182+
@app.client.callback
183+
async def on_send_click(event: Event):
184+
from violetear.client import get_client_id
185+
186+
text_el = DOM.find("message-input")
187+
text = str(text_el.value or "")
188+
if not text.strip():
189+
return
190+
await post_message(client_id=str(get_client_id()), text=text)
191+
text_el.value = ""
192+
193+
194+
@app.client.callback
195+
async def on_rename_click(event: Event):
196+
from violetear.client import get_client_id
197+
198+
name_el = DOM.find("name-input")
199+
new_name = str(name_el.value or "")
200+
if not new_name.strip():
201+
return
202+
await set_name(client_id=str(get_client_id()), new_name=new_name)
203+
204+
205+
# ---------------------------------------------------------------------------
206+
# Stylesheet — flat class selectors only (gap 7.1)
207+
# ---------------------------------------------------------------------------
208+
209+
210+
sheet = StyleSheet(normalize=True)
211+
212+
sheet.select("body").font(
213+
size=rem(1.0), family="system-ui, -apple-system, 'Segoe UI', sans-serif"
214+
).color(Colors.DarkSlateGray).background(Colors.WhiteSmoke).padding(rem(1.5))
215+
216+
sheet.select(".page").rules(max_width="900px").margin("auto").background(
217+
Colors.White
218+
).padding(rem(1.5)).rounded(px(8)).border(px(1), Colors.Gainsboro)
219+
220+
sheet.select(".page-title").font(size=rem(1.5), weight=700).color(Colors.Indigo).margin(
221+
bottom=rem(1.0)
222+
)
223+
224+
sheet.select(".layout").flexbox(direction="row", gap=px(16)).rules(min_height="420px")
225+
226+
# Sidebar
227+
sheet.select(".sidebar").rules(min_width="180px", flex="0 0 180px").padding(
228+
rem(0.75)
229+
).background(Colors.WhiteSmoke).rounded(px(6)).border(px(1), Colors.Gainsboro)
230+
sheet.select(".sidebar-heading").font(size=rem(0.875), weight=700).color(
231+
Colors.SlateGray
232+
).rules(text_transform="uppercase", letter_spacing="0.05em").margin(bottom=rem(0.5))
233+
sheet.select(".user-list").flexbox(direction="column", gap=px(4))
234+
sheet.select(".user-row").font(size=rem(0.875)).color(Colors.DarkSlateGray).rules(
235+
padding="4px 6px"
236+
).background(Colors.White).rounded(px(4))
237+
238+
# Main column
239+
sheet.select(".main").flexbox(direction="column", gap=px(12)).rules(flex="1")
240+
sheet.select(".chat-log").flexbox(direction="column", gap=px(6)).rules(
241+
flex="1", overflow_y="auto", padding="8px"
242+
).background(Colors.WhiteSmoke).rounded(px(6)).border(px(1), Colors.Gainsboro)
243+
sheet.select(".chat-row").flexbox(direction="row", gap=px(8), align="baseline").rules(
244+
padding="4px 6px"
245+
)
246+
sheet.select(".chat-row-system").color(Colors.SlateGray).rules(font_style="italic")
247+
sheet.select(".chat-from").font(size=rem(0.875), weight=700).color(Colors.Indigo)
248+
sheet.select(".chat-text").font(size=rem(0.875)).color(Colors.DarkSlateGray)
249+
250+
# Compose bar
251+
sheet.select(".compose").flexbox(direction="row", gap=px(8))
252+
sheet.select(".compose-input").rules(
253+
padding="8px 10px",
254+
border=f"1px solid {Colors.Gainsboro}",
255+
font_size="1rem",
256+
flex="1",
257+
).rounded(px(4))
258+
sheet.select(".compose-button").rules(
259+
padding="8px 14px",
260+
border="none",
261+
cursor="pointer",
262+
font_size="0.875rem",
263+
font_weight=600,
264+
).background(Colors.Indigo).color(Colors.White).rounded(px(4))
265+
266+
# Rename bar
267+
sheet.select(".rename-row").flexbox(direction="row", gap=px(8), align="center").margin(
268+
top=rem(0.5)
269+
)
270+
sheet.select(".rename-label").font(size=rem(0.875), weight=600).color(Colors.SlateGray)
271+
sheet.select(".rename-input").rules(
272+
padding="6px 8px",
273+
border=f"1px solid {Colors.Gainsboro}",
274+
font_size="0.875rem",
275+
).rounded(px(4))
276+
sheet.select(".rename-button").rules(
277+
padding="6px 10px",
278+
border=f"1px solid {Colors.Gainsboro}",
279+
cursor="pointer",
280+
font_size="0.875rem",
281+
font_weight=600,
282+
).background(Colors.White).color(Colors.DarkSlateGray).rounded(px(4))
283+
284+
285+
# ---------------------------------------------------------------------------
286+
# View
287+
# ---------------------------------------------------------------------------
288+
289+
290+
@app.view("/")
291+
def index():
292+
doc = Document(title="Chat Room")
293+
doc.style(href="/style.css", sheet=sheet)
294+
295+
with doc.body as body:
296+
with body.div(classes="page") as page:
297+
page.h1(text="Chat Room").classes("page-title")
298+
299+
with page.div(classes="layout") as layout:
300+
# Sidebar — live user list, filled by receive_history /
301+
# set_user_list pushes.
302+
with layout.div(classes="sidebar") as sidebar:
303+
sidebar.div(text="Online", classes="sidebar-heading")
304+
sidebar.div(classes="user-list").attrs(id="user-list")
305+
306+
with layout.div(classes="main") as main:
307+
# Chat log — filled by receive_message + receive_history.
308+
main.div(classes="chat-log").attrs(id="chat-log")
309+
310+
# Compose bar.
311+
with main.div(classes="compose") as compose:
312+
compose.input(classes="compose-input").attrs(
313+
id="message-input",
314+
type="text",
315+
placeholder="Type a message…",
316+
)
317+
compose.button(text="Send", classes="compose-button").attrs(
318+
type="button"
319+
).on("click", on_send_click)
320+
321+
# Rename bar.
322+
with main.div(classes="rename-row") as rename:
323+
rename.span(text="Display name:", classes="rename-label")
324+
rename.input(classes="rename-input").attrs(
325+
id="name-input", type="text"
326+
)
327+
rename.button(text="Rename", classes="rename-button").attrs(
328+
type="button"
329+
).on("click", on_rename_click)
330+
331+
return doc
332+
333+
334+
if __name__ == "__main__":
335+
app.run()

issues/7-framework-gaps-from-canonical-examples.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,18 @@ Running log of small framework limitations and ergonomic friction discovered whi
8989

9090
**Impact.** Reference examples can't use `for=` / `class=` via kwargs without producing invalid HTML. Subtle because the form still submits (browsers ignore unknown attrs), so the bug is silent until someone inspects the rendered HTML.
9191

92+
## 7.8 — `get_client_id()` returns a `Thing` proxy on subsequent calls
93+
94+
**Tier(s):** 05 (caught by Playwright e2e — silent in unit tests)
95+
96+
**Symptom.** `violetear.client.get_client_id()` is documented as returning a `str`. On first call (no session storage entry) it does — it generates a UUID, writes it via `session["VIOLETEAR_ID"] = client_id`, and returns the bare `str`. On every subsequent call it goes through `session.get("VIOLETEAR_ID")`, which returns a `storage.Thing` wrapper (the auto-persist proxy used for nested object mutation tracking) — not a `str`. Calling `_call_realtime(..., kwargs={"client_id": <Thing>})` then `json.dumps` the kwargs and blows up: `TypeError: Object of type Thing is not JSON serializable`. The error surfaces only in Pyodide at runtime; unit tests using `TestClient.websocket_connect` pass the client_id directly as a string and never hit this path.
97+
98+
**Workaround applied.** Coerce explicitly at every call site in 05_realtime: `client_id=str(get_client_id())`. `Thing.__repr__` returns `f"{self._data}"`, so `str(Thing("abc"))` returns `"abc"`. Verbose, but works today.
99+
100+
**Where to fix in the framework.** `violetear/client.py:get_client_id` — make the function always return a plain `str`. Either unwrap before returning (`if hasattr(client_id, "unwrap"): client_id = client_id.unwrap()`) or read through a path that bypasses `Thing` wrapping for primitive values. The latter is structurally cleaner — `StorageAPI.__getitem__` wraps everything in `Thing` even when the underlying value is a primitive string/number, which makes sense for dict/list mutation tracking but is overkill for primitives. A `StorageAPI.get_raw(key)` that skips the wrap would let internal helpers like `get_client_id` avoid the proxy entirely.
101+
102+
**Larger lesson.** Functions that promise a primitive return type but actually return a smart-wrapper on some code paths are a bug magnet — they pass through arithmetic, comparisons, and string formatting fine but explode at the first json/pickle boundary. Type-annotated `-> str` is a check the framework's own helpers should satisfy strictly.
103+
92104
---
93105

94106
Add entries here as more examples land.

0 commit comments

Comments
 (0)