diff --git a/lib/dream/conformance.sh b/lib/dream/conformance.sh
index 2cd59de2..7596f771 100644
--- a/lib/dream/conformance.sh
+++ b/lib/dream/conformance.sh
@@ -33,6 +33,8 @@ MODULES=(
"lib/dream/run.sx"
"lib/dream/demos/hello.sx"
"lib/dream/demos/counter.sx"
+ "lib/dream/demos/chat.sx"
+ "lib/dream/demos/todo.sx"
)
# Suites: NAME RUNNER-FN PATH
diff --git a/lib/dream/demos/chat.sx b/lib/dream/demos/chat.sx
new file mode 100644
index 00000000..b932d085
--- /dev/null
+++ b/lib/dream/demos/chat.sx
@@ -0,0 +1,46 @@
+;; lib/dream/demos/chat.sx — multi-room WebSocket chat (chat.ml).
+;; A room registry holds the live connections per room; each ws session joins its
+;; room, broadcasts every received message to the room, and leaves on close.
+
+(define dream-chat-rooms (fn () (let ((rooms {})) {:join (fn (room ws) (set! rooms (assoc rooms room (concat (or (get rooms room) (list)) (list ws))))) :broadcast (fn (room msg) (for-each (fn (w) (dream-send w msg)) (or (get rooms room) (list)))) :members (fn (room) (or (get rooms room) (list))) :leave (fn (room ws) (set! rooms (assoc rooms room (filter (fn (w) (not (= w ws))) (or (get rooms room) (list))))))})))
+
+(define
+ dream-chat-loop
+ (fn
+ (rooms room ws)
+ (let
+ ((m (dream-receive ws)))
+ (if
+ (nil? m)
+ (begin ((get rooms :leave) room ws) (dream-close ws))
+ (begin
+ ((get rooms :broadcast) room m)
+ (dream-chat-loop rooms room ws))))))
+
+(define
+ dream-chat-session
+ (fn
+ (rooms room)
+ (fn
+ (ws)
+ (begin ((get rooms :join) room ws) (dream-chat-loop rooms room ws)))))
+
+(define
+ dream-chat-route
+ (fn
+ (rooms)
+ (fn
+ (req)
+ ((dream-websocket (dream-chat-session rooms (dream-param req "room")))
+ req))))
+
+(define
+ dream-chat-app-with
+ (fn
+ (rooms)
+ (dream-router
+ (list
+ (dream-get "/" (fn (req) (dream-html "
Rooms
")))
+ (dream-get "/chat/:room" (dream-chat-route rooms))))))
+
+;; entry point: (dream-run (dream-chat-app-with (dream-chat-rooms)))
diff --git a/lib/dream/demos/todo.sx b/lib/dream/demos/todo.sx
new file mode 100644
index 00000000..72317b22
--- /dev/null
+++ b/lib/dream/demos/todo.sx
@@ -0,0 +1,95 @@
+;; lib/dream/demos/todo.sx — CRUD todo list with forms + CSRF (todo.ml).
+;; An in-memory store holds items; add/toggle/delete go through POST forms guarded
+;; by the CSRF middleware. Wires session -> csrf -> router.
+
+(define
+ dream-todo-store
+ (fn () (let ((items (list)) (next-id 0)) {:all (fn () items) :add (fn (text) (begin (set! next-id (+ next-id 1)) (set! items (concat items (list {:id next-id :text text :done false}))) next-id)) :delete (fn (id) (set! items (filter (fn (it) (not (= (get it :id) id))) items))) :toggle (fn (id) (set! items (map (fn (it) (if (= (get it :id) id) (assoc it :done (not (get it :done))) it)) items)))})))
+
+(define
+ dr/todo-render
+ (fn
+ (store req)
+ (str
+ ""
+ (reduce
+ (fn
+ (acc it)
+ (str
+ acc
+ "- "
+ (if (get it :done) "[x] " "[ ] ")
+ (get it :text)
+ "
"))
+ ""
+ ((get store :all)))
+ "
"
+ "")))
+
+(define
+ dream-todo-index
+ (fn (store) (fn (req) (dream-html (dr/todo-render store req)))))
+
+(define
+ dream-todo-add
+ (fn
+ (store)
+ (fn
+ (req)
+ (let
+ ((r (dream-form req)))
+ (if
+ (dream-ok? r)
+ (begin
+ ((get store :add) (get (dream-ok-value r) "text"))
+ (dream-redirect "/"))
+ (dream-html-status
+ 403
+ (str "Rejected: " (dream-err-reason r))))))))
+
+(define
+ dream-todo-toggle
+ (fn
+ (store)
+ (fn
+ (req)
+ (let
+ ((r (dream-form req)))
+ (if
+ (dream-ok? r)
+ (begin
+ ((get store :toggle) (parse-int (dream-param req "id")))
+ (dream-redirect "/"))
+ (dream-html-status 403 "Rejected"))))))
+
+(define
+ dream-todo-delete
+ (fn
+ (store)
+ (fn
+ (req)
+ (let
+ ((r (dream-form req)))
+ (if
+ (dream-ok? r)
+ (begin
+ ((get store :delete) (parse-int (dream-param req "id")))
+ (dream-redirect "/"))
+ (dream-html-status 403 "Rejected"))))))
+
+(define
+ dream-todo-app-with
+ (fn
+ (store backend secret)
+ ((dream-sessions backend)
+ ((dream-csrf secret)
+ (dream-router
+ (list
+ (dream-get "/" (dream-todo-index store))
+ (dream-post "/add" (dream-todo-add store))
+ (dream-post "/toggle/:id" (dream-todo-toggle store))
+ (dream-post "/delete/:id" (dream-todo-delete store))))))))
+
+;; entry: (dream-run (dream-todo-app-with (dream-todo-store) (dream-memory-sessions) "change-me"))
diff --git a/lib/dream/tests/demos.sx b/lib/dream/tests/demos.sx
index 7394dbd9..4e5c1d23 100644
--- a/lib/dream/tests/demos.sx
+++ b/lib/dream/tests/demos.sx
@@ -40,7 +40,6 @@
(define dream-dm-cbackend (dream-memory-sessions))
(define dream-dm-capp (dream-counter-app-with dream-dm-cbackend))
-;; first visit: no cookie -> count 1, session cookie set
(define dream-dm-c1 (dream-dm-capp (dream-dm-req "GET" "/" {})))
(dream-dm-test
"counter first visit"
@@ -50,8 +49,6 @@
"counter sets cookie"
(len (dream-resp-cookies dream-dm-c1))
1)
-
-;; subsequent visits with the cookie increment
(dream-dm-test
"counter second visit"
(dream-resp-body (dream-dm-capp (dream-dm-req "GET" "/" {:Cookie "dream.session=s1"})))
@@ -60,8 +57,6 @@
"counter third visit"
(dream-resp-body (dream-dm-capp (dream-dm-req "GET" "/" {:Cookie "dream.session=s1"})))
"You have visited this page 3 time(s).
")
-
-;; reset zeroes the counter then redirects
(define
dream-dm-reset
(dream-dm-capp (dream-dm-req "POST" "/reset" {:Cookie "dream.session=s1"})))
@@ -73,11 +68,131 @@
"counter after reset"
(dream-resp-body (dream-dm-capp (dream-dm-req "GET" "/" {:Cookie "dream.session=s1"})))
"You have visited this page 1 time(s).
")
-
-;; a different session is independent
(dream-dm-test
"counter distinct session"
(dream-resp-body (dream-dm-capp (dream-dm-req "GET" "/" {})))
"You have visited this page 1 time(s).
")
+;; ── chat (websocket rooms) ─────────────────────────────────────────
+(define dream-dm-rooms (dream-chat-rooms))
+(define dream-dm-wsB (dream-mock-ws (list)))
+(define dream-dm-wsC (dream-mock-ws (list)))
+((get dream-dm-rooms :join) "general" dream-dm-wsB)
+((get dream-dm-rooms :join) "general" dream-dm-wsC)
+(dream-dm-test
+ "room has two members"
+ (len ((get dream-dm-rooms :members) "general"))
+ 2)
+
+;; client A joins, sends two messages, then disconnects
+(define dream-dm-wsA (dream-mock-ws (list "hi" "again")))
+((dream-chat-session dream-dm-rooms "general") dream-dm-wsA)
+(dream-dm-test
+ "B got broadcasts"
+ (dream-ws-sent dream-dm-wsB)
+ (list "hi" "again"))
+(dream-dm-test
+ "C got broadcasts"
+ (dream-ws-sent dream-dm-wsC)
+ (list "hi" "again"))
+(dream-dm-test
+ "A echoed own messages"
+ (dream-ws-sent dream-dm-wsA)
+ (list "hi" "again"))
+(dream-dm-test
+ "A left on disconnect"
+ (len ((get dream-dm-rooms :members) "general"))
+ 2)
+(dream-dm-test "A closed" (dream-ws-closed? dream-dm-wsA) true)
+
+;; route produces an upgrade response
+(define dream-dm-chat-app (dream-chat-app-with (dream-chat-rooms)))
+(dream-dm-test
+ "chat route upgrades"
+ (dream-websocket?
+ (dream-dm-chat-app (dream-dm-req "GET" "/chat/lobby" {})))
+ true)
+(dream-dm-test
+ "chat index html"
+ (dream-resp-body (dream-dm-chat-app (dream-dm-req "GET" "/" {})))
+ "Rooms
")
+
+;; ── todo (forms + CSRF) ────────────────────────────────────────────
+(define dream-dm-todo-store (dream-todo-store))
+(define dream-dm-todo-backend (dream-memory-sessions))
+(define
+ dream-dm-todo-app
+ (dream-todo-app-with dream-dm-todo-store dream-dm-todo-backend "topsecret"))
+(define
+ dream-dm-todo-tok
+ (dr/csrf-make-token dream-csrf-sign-default "topsecret" "s1"))
+
+;; establish session s1
+(dream-dm-todo-app (dream-request "GET" "/" {} ""))
+(define
+ dream-dm-add1
+ (dream-dm-todo-app
+ (dream-request
+ "POST"
+ "/add"
+ {:Cookie "dream.session=s1"}
+ (str "text=Buy+milk&dream.csrf=" dream-dm-todo-tok))))
+(dream-dm-test "todo add redirects" (dream-status dream-dm-add1) 303)
+(dream-dm-test
+ "todo store has item"
+ (len ((get dream-dm-todo-store :all)))
+ 1)
+
+(define
+ dream-dm-todo-page
+ (dream-resp-body
+ (dream-dm-todo-app (dream-request "GET" "/" {:Cookie "dream.session=s1"} ""))))
+(dream-dm-test
+ "todo lists item"
+ (contains? dream-dm-todo-page "Buy milk")
+ true)
+(dream-dm-test
+ "todo has csrf tag"
+ (contains? dream-dm-todo-page "dream.csrf")
+ true)
+(dream-dm-test
+ "todo item not done"
+ (contains? dream-dm-todo-page "[ ] Buy milk")
+ true)
+
+(dream-dm-todo-app
+ (dream-request
+ "POST"
+ "/toggle/1"
+ {:Cookie "dream.session=s1"}
+ (str "dream.csrf=" dream-dm-todo-tok)))
+(dream-dm-test
+ "todo toggled done"
+ (contains?
+ (dream-resp-body
+ (dream-dm-todo-app (dream-request "GET" "/" {:Cookie "dream.session=s1"} "")))
+ "[x] Buy milk")
+ true)
+
+(dream-dm-test
+ "todo add without token 403"
+ (dream-status
+ (dream-dm-todo-app (dream-request "POST" "/add" {:Cookie "dream.session=s1"} "text=Sneaky")))
+ 403)
+(dream-dm-test
+ "todo unchanged after reject"
+ (len ((get dream-dm-todo-store :all)))
+ 1)
+
+(dream-dm-todo-app
+ (dream-request
+ "POST"
+ "/delete/1"
+ {:Cookie "dream.session=s1"}
+ (str "dream.csrf=" dream-dm-todo-tok)))
+(dream-dm-test
+ "todo deleted"
+ (len ((get dream-dm-todo-store :all)))
+ 0)
+
(define dream-dm-tests-run! (fn () {:total (+ dream-dm-pass dream-dm-fail) :passed dream-dm-pass :failed dream-dm-fail :fails dream-dm-fails}))
diff --git a/plans/dream-on-sx.md b/plans/dream-on-sx.md
index 7438d325..7ff8e2e6 100644
--- a/plans/dream-on-sx.md
+++ b/plans/dream-on-sx.md
@@ -74,11 +74,11 @@ The five types: `request`, `response`, `handler = request -> response`, `middlew
- `dream-send ws msg`, `dream-receive ws`, `dream-close ws`.
- [x] **Static files:** `dream-static root-path` — serves files, ETags, range requests.
- [x] **`dream-run`**: wires root handler into SX's `perform (:http-listen ...)`.
-- [~] **Demos** in `lib/dream/demos/`:
+- [x] **Demos** in `lib/dream/demos/`:
- [x] `hello.ml` → `lib/dream/demos/hello.sx`: "Hello, World!" route.
- [x] `counter.ml` → `lib/dream/demos/counter.sx`: in-memory counter with sessions.
- - [ ] `chat.ml` → `lib/dream/demos/chat.sx`: multi-room WebSocket chat.
- - [ ] `todo.ml` → `lib/dream/demos/todo.sx`: CRUD list with forms + CSRF.
+ - [x] `chat.ml` → `lib/dream/demos/chat.sx`: multi-room WebSocket chat.
+ - [x] `todo.ml` → `lib/dream/demos/todo.sx`: CRUD list with forms + CSRF.
- [ ] Tests in `lib/dream/tests/`: routing dispatch, middleware composition, session round-trip, CSRF accept/reject, flash read-after-write — 60+ tests.
## Stdlib additions Dream will need
@@ -203,6 +203,14 @@ Confirm scope before starting; some of these may be addable as Dream-internal he
visit counter on the session middleware (+ a `/reset` POST that redirects),
demonstrating session isolation across browsers. End-to-end tests drive both apps
as the host would. chat (ws) + todo (forms+CSRF) next.
+- **2026-06-07 — Demos: chat + todo** (`lib/dream/demos/`, demos suite now 27 tests).
+ `chat.sx` is a multi-room WebSocket chat over a room registry (join/leave/members/
+ broadcast on the cell pattern); verified three clients see each other's broadcasts
+ and a disconnect leaves the room. `todo.sx` is a CRUD list wiring session→csrf→
+ router: add/toggle/delete go through `dream-form` (CSRF-guarded), an in-memory store
+ holds items, pages render the list + `dream-csrf-tag`; verified the full
+ add→render→toggle→delete cycle plus a 403 on a token-less POST. ws object equality
+ is by reference, so the `:leave` filter removes exactly the right connection.
## Blockers