Skip to content

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

cd examples/chat
fitz 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 with fitz_liveviews path dependency pointing to the parent library.
  • src/main.fitz — the whole chat, ~60 lines:
    1. type Message { author, text } — a chat message
    2. type ChatRoom { messages: List<Message> } — the shared state
    3. let chat_room: ChatRoom = ChatRoom { messages: [] } — top-level let becomes Arc<Mutex<ChatRoom>> under F17, so mutations from any WebSocket handler are visible to all connections
    4. let last_chat_html: Str = "" — snapshot of the last broadcast, used to compute diffs
    5. fn render_chat(room) — turns state into Html
    6. @get("/") — initial page render
    7. @ws("/live/chat") — receives events, mutates shared state, computes patches from the snapshot, calls ws.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

let chat_room: ChatRoom = ChatRoom { messages: [] }

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

ws.broadcast(LiveFrame { html: new_html, patches: patches })?

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 event
  • data-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 GET and then receives a broadcast patch that was diffed from an older server snapshot, the patches may not apply cleanly. The html fallback recovers, but silently.