Chat — multi-user real-time (Phase 3a + 3b)¶
Multi-user chat over a single WebSocket. Type your name and a message, hit send, and every open browser window sees it appear — with the author input persisted across sends and the message input cleared, because Phase 3b's diff engine patches the DOM without re-mounting the form.
Source: examples/chat/
Run¶
Open http://127.0.0.1:3000/ in two browser windows. Type a name and a message in one window and send — the message appears in both windows instantly. This is the "wow moment" of LiveViews.
What each file does¶
fitz.toml— package manifest withfitz_liveviewspath dependency pointing to the parent library.src/main.fitz— the whole chat, ~60 lines:type Message { author, text }— a chat messagetype ChatRoom { messages: List<Message> }— the shared statelet chat_room: ChatRoom = ChatRoom { messages: [] }— top-levelletbecomesArc<Mutex<ChatRoom>>under F17, so mutations from any WebSocket handler are visible to all connectionslet last_chat_html: Str = ""— snapshot of the last broadcast, used to compute diffsfn render_chat(room)— turns state intoHtml@get("/")— initial page render@ws("/live/chat")— receives events, mutates shared state, computes patches from the snapshot, callsws.broadcast(...)
The whole source¶
// Fitz LiveViews — multi-user chat (Phase 8.4 SFC migration).
//
// Migrated from classic shared-state pattern (top-level `let
// chat_room` + manual event dispatch + `chat_room.messages.push`)
// to `.fitzv` SFC pattern (`ChatRoom.fitzv` owns state + event +
// template; `main.fitz` shrinks to just the HTTP + WS handlers).
//
// Type `Message` lives in a dedicated sibling module
// (`message.fitz`) so both `main.fitz` (for WS `LiveFrame` shape)
// AND `ChatRoom.fitzv` (for state + event body struct literal)
// can import it without creating a circular dependency.
//
// Requires Fitz core v0.21.0+ with §9.cc + §9.dd + §9.ee closures:
// - §9.cc V-4: `payload` in view checker event-body scope
// - §9.cc V-6: bare `messages.push()` in shadow-local event body
// - §9.dd V-3/V-5: `from message import Message` in `.fitzv`
// - §9.ee V-2: bare boolean HTML attrs (`required`) in template
//
// Run: fitz run
// Then open http://127.0.0.1:3000/ in two browser windows. Type
// a name and a message in one window; the other window receives
// it instantly via WebSocket broadcast.
from fitz_liveviews import Html, html, live_layout, html_response, LiveFrame, diff_html, component, dispatch_component_events, flv_register
from ChatRoom import ChatRoom, ChatRoom_render, ChatRoom_send_message
from message import Message
// -----------------------------------------------------------------------------
// Boot registration is IMPLICIT (Fitz core v0.21.0+)
// -----------------------------------------------------------------------------
//
// The compiler auto-generates `flv_register("ChatRoom", ChatRoom { },
// ChatRoom_render, {"send_message": ChatRoom_send_message})` from
// the metadata that `@live_component`/`@render_for`/`@on` leave in
// the imported `ChatRoom.fitzv` module (Phase 11.6.e §9.bb cross-
// module auto-inject). The `from ChatRoom import ...` line above
// brings the required names into scope.
// -----------------------------------------------------------------------------
// Server-side snapshot of the last broadcast HTML
// -----------------------------------------------------------------------------
//
// Diffing against this yields the compact patch list every WS
// broadcast ships to clients. Shared (like the component state)
// because every broadcast is derived from the canonical server
// state, not from any individual connection.
let last_chat_html: Str = ""
// -----------------------------------------------------------------------------
// HTTP GET — initial render + client runtime
// -----------------------------------------------------------------------------
@get("/")
fn chat_page() -> Response {
let initial = component("ChatRoom", "room")
return html_response(live_layout("/live/chat", "chat-app", initial))
}
// -----------------------------------------------------------------------------
// WebSocket — event/patch cycle
// -----------------------------------------------------------------------------
//
// The parent handler has ZERO event branches — every event is a
// component event routed via `dispatch_component_events(frame)`.
// Compare to the pre-migration WS handler that hand-rolled
// `if (frame.event == "send_message") { if (frame.payload.has(...))
// { ... chat_room.messages.push(Message { ... }) ... } }`.
@ws("/live/chat")
async fn chat_socket(ws: WsConn<LiveFrame>) {
loop {
let frame = ws.recv()?
if (last_chat_html == "") {
last_chat_html = component("ChatRoom", "room").raw
}
let _handled = dispatch_component_events(frame)
let new_html = component("ChatRoom", "room").raw
let patches = diff_html(last_chat_html, new_html)
ws.broadcast(LiveFrame { html: new_html, patches: patches })?
last_chat_html = new_html
}
}
// -----------------------------------------------------------------------------
// Server config
// -----------------------------------------------------------------------------
@server(3000)
fn main() => 0
The three magic pieces¶
Shared state via top-level let¶
That single line is the entire "multi-user backend". No Redis, no
external state store. Fitz's F17 wraps every top-level let in
Arc<Mutex<T>>.
Broadcast via ws.broadcast(...)¶
Fitz's WebSocket runtime tracks every connected client of an endpoint.
broadcast(msg) sends msg to all of them — sender included,
matching the Phoenix / Socket.IO convention.
Forms via data-flv-submit and data-flv-clear¶
<form data-flv-submit="send_message">
<input name="author" placeholder="Your name" />
<input name="text" placeholder="Your message" data-flv-clear />
</form>
data-flv-submit— intercept form submit, package inputs into payload map, send as eventdata-flv-clear— after the form submits, wipe this input (opt-in; without it, the input value is preserved)
The lazy-snapshot trick¶
Notice how the handler does this on the first message:
if (last_chat_html == "") {
last_chat_html = render_chat(chat_room).raw
}
chat_room.messages.push(...)
Without this init, the first diff would be "" → full div HTML,
which the guard in diff_html rejects, and the client would fall
back to full HTML replace — wiping the form values the user just
typed. Snapshotting the BEFORE-mutation render lets the first diff
produce clean append patches for the new <li> without touching
the form.
What proves it works¶
- Two browser windows.
- Both windows see the same messages in the same order.
- New messages appear in the other window within milliseconds.
- The author input keeps its value across every send — that's the Phase 3b diff engine preserving DOM state.
- The message input clears after send — because it has
data-flv-clear. - HTML escaping neutralizes any
<script>payload sent as author or text — try typing<script>alert('xss')</script>as your text; you will see the literal characters, no popup.
Known limitations¶
- No persistence. Restart the server and the messages disappear. Persistent storage plugs into Fitz's ORM (Phase 4 territory).
- No presence. No "Bob joined" / "Bob is typing" — those need lifecycle hooks (Phase 3c or Phase 4).
- No moderation. Every author is trusted. Real chats need
authentication (Phase 3c
@authenticated @ws(...)). - Race on newly-connected clients during broadcast. If a client
finishes its HTTP
GETand then receives a broadcast patch that was diffed from an older server snapshot, the patches may not apply cleanly. Thehtmlfallback recovers, but silently.