From b1be3a36ec8cf6cb413256428b9f3f2d72c6f052 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 14:57:17 +0000 Subject: [PATCH] dream: chat (ws rooms) + todo (forms+CSRF) demos + 17 tests Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/dream/conformance.sh | 2 + lib/dream/demos/chat.sx | 46 ++++++++++++++ lib/dream/demos/todo.sx | 95 ++++++++++++++++++++++++++++ lib/dream/tests/demos.sx | 129 ++++++++++++++++++++++++++++++++++++--- plans/dream-on-sx.md | 14 ++++- 5 files changed, 276 insertions(+), 10 deletions(-) create mode 100644 lib/dream/demos/chat.sx create mode 100644 lib/dream/demos/todo.sx 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 + "" + "
" + (dream-csrf-tag req) + "
"))) + +(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