diff --git a/lib/dream/README.md b/lib/dream/README.md
new file mode 100644
index 00000000..d7c533b5
--- /dev/null
+++ b/lib/dream/README.md
@@ -0,0 +1,79 @@
+# dream-on-sx
+
+OCaml's [Dream](https://aantron.github.io/dream/) web framework, reimplemented in
+**plain SX** on the CEK evaluator. Dream is the cleanest middleware-shaped HTTP
+framework in any language, and it maps onto SX with almost no impedance:
+
+| Dream | SX |
+|-------|-----|
+| `handler = request -> response promise` | `(fn (req) … (perform …))` |
+| `middleware = handler -> handler` | `(fn (next) (fn (req) …))` |
+| `m1 @@ m2 @@ handler` | `(m1 (m2 handler))` — left fold |
+| `Dream.run handler` | `(dream-run handler)` → `(perform (:http/listen …))` |
+
+There are five types — **request, response, route**, and (as plain functions)
+**handler** and **middleware**. Everything else is a function over them.
+
+## Quickstart
+
+```lisp
+(dream-run
+ (dream-make-app
+ (list
+ (dream-get "/" (fn (req) (dream-html "
Hello, World!
")))
+ (dream-get "/hello/:name"
+ (fn (req) (dream-text (str "Hi, " (dream-param req "name"))))))))
+```
+
+`dream-make-app` wraps the router in the default stack (error catch + content-type).
+`dream-run` installs the root handler on the existing SX HTTP server — it does **not**
+open its own socket.
+
+## Public surface
+
+- **types** — `dream-request`/`dream-response`/`dream-route`, accessors
+ (`dream-method`/`-path`/`-body`/`-header`/`-query-param`/`-param`), smart
+ constructors (`dream-html`/`-text`/`-json`/`-empty`/`-not-found`/`-redirect`),
+ convenience (`dream-queries`, `*-or` defaults, `dream-accepts?`/`dream-wants-json?`).
+- **router** — `dream-get`/`-post`/`-put`/`-delete`/`-patch`/`-head`/`-options`/`-any`,
+ `dream-router`, `dream-scope` (prefix + middleware), `:name` params + `**` catch-all,
+ 405 + `Allow`, automatic HEAD.
+- **middleware** — `dream-pipeline`, `dream-no-middleware`, `dream-logger`,
+ `dream-content-type`, `dream-set-header`, `dream-tap-request`.
+- **session** — `dream-sessions` / `dream-sessions-signed`, `dream-session-field` /
+ `dream-set-session-field` / `dream-session-all` / `dream-invalidate-session`; cookie
+ helpers (`dream-cookie`, `dream-set-cookie`, `dream-cookie-sign`/`-unsign`).
+- **flash** — `dream-flash`, `dream-add-flash-message`, `dream-flash-messages`.
+- **form** — `dream-form` (Ok/Err), `dream-form-fields`, `dream-multipart`, CSRF
+ (`dream-csrf` / `dream-csrf-protect` / `dream-csrf-token` / `dream-csrf-tag`).
+- **websocket** — `dream-websocket`, `dream-send`/`-receive`/`-close`/`-broadcast`.
+- **static** — `dream-static` (mime, ETags, 304, ranges, traversal guard).
+- **error** — `dream-catch`, `dream-status-text`/`-line`, `dream-status-page`.
+- **cors** — `dream-cors`, `dream-cors-origin`, `dream-cors-with`.
+- **json** — `dream-json-encode`/`-parse`, `dream-json-value`, `dream-json-body`.
+- **run / api** — `dream-run`/`-port`/`-opts`, `dream-app`, `dream-make-app`,
+ `dream-serve`.
+
+## Testing story
+
+Every effectful concern is **dependency-injected**, so the whole framework is testable
+without a running host:
+
+- sessions take a backend `(fn (op) …)` — `dream-memory-sessions` for tests,
+ `dream-perform-sessions` in production;
+- static files take an fs — `dream-memory-fs` vs `dream-static-perform-fs`;
+- websockets take an io — `dream-mock-ws` vs `dream-ws-perform-io`;
+- `dream-run` takes a listen transport (`dream-run-with`).
+
+Run the suite: `bash lib/dream/conformance.sh` (367 tests, 14 suites).
+
+## Notes & caveats
+
+- Headers are dicts with **lowercased string keys** (in SX keywords *are* strings, so
+ `:content-type` == `"content-type"`).
+- Outgoing cookies accumulate in a `:set-cookies` list on the response so multiple
+ `Set-Cookie` headers don't collide.
+- The CSRF/cookie/ETag signing uses a pure-SX keyed hash — **not cryptographic**.
+ Production should inject a host HMAC (`dream-csrf-with`, and the signed-session
+ secret path).
+- JSON and multipart are in-memory (not streaming).
diff --git a/lib/dream/api.sx b/lib/dream/api.sx
new file mode 100644
index 00000000..63b1850b
--- /dev/null
+++ b/lib/dream/api.sx
@@ -0,0 +1,33 @@
+;; lib/dream/api.sx — Dream-on-SX public facade.
+;; Loaded last; bundles the modules into a batteries-included surface. The full
+;; public API is the `dream-*` functions across types/router/middleware/session/
+;; flash/form/websocket/static/error/cors/json/run; this file adds convenience
+;; app builders. Depends on all other dream modules.
+
+(define dream-version "0.1.0")
+
+;; standard middleware stack (pure — no IO): error catch outermost, then
+;; content-type sniffing. Logger is opt-in since it performs host IO.
+(define
+ dream-defaults
+ (fn
+ (handler)
+ (dream-pipeline (list dream-catch dream-content-type) handler)))
+
+;; build a complete app handler from a route list with the default stack
+(define
+ dream-make-app
+ (fn (routes) (dream-defaults (dream-router routes))))
+
+;; build an app and wrap it with extra middleware (outermost first)
+(define
+ dream-make-app-with
+ (fn
+ (middlewares routes)
+ (dream-pipeline middlewares (dream-make-app routes))))
+
+;; one-call serve: routes + opts -> installed on the host
+(define
+ dream-serve
+ (fn (routes opts) (dream-run-opts (dream-make-app routes) opts)))
+(define dream-serve-port (fn (routes port) (dream-serve routes {:port port})))
diff --git a/lib/dream/auth.sx b/lib/dream/auth.sx
new file mode 100644
index 00000000..16c5dd61
--- /dev/null
+++ b/lib/dream/auth.sx
@@ -0,0 +1,172 @@
+;; lib/dream/auth.sx — Dream-on-SX authentication helpers.
+;; HTTP Basic auth (with a pure-SX base64 codec) and Bearer-token guards.
+;; Depends on types.sx.
+
+;; ── base64 (pure SX; arithmetic, no bitwise) ───────────────────────
+(define
+ dr/b64-alpha
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")
+(define dr/b64-char (fn (n) (char-at dr/b64-alpha n)))
+(define dr/b64-index (fn (c) (index-of dr/b64-alpha c)))
+
+(define
+ dr/b64-encode-loop
+ (fn
+ (s i n acc)
+ (if
+ (>= i n)
+ acc
+ (let
+ ((b0 (char-code (char-at s i))) (rem (- n i)))
+ (cond
+ ((>= rem 3)
+ (let
+ ((triple (+ (* b0 65536) (* (char-code (char-at s (+ i 1))) 256) (char-code (char-at s (+ i 2))))))
+ (dr/b64-encode-loop
+ s
+ (+ i 3)
+ n
+ (str
+ acc
+ (dr/b64-char (mod (quotient triple 262144) 64))
+ (dr/b64-char (mod (quotient triple 4096) 64))
+ (dr/b64-char (mod (quotient triple 64) 64))
+ (dr/b64-char (mod triple 64))))))
+ ((= rem 2)
+ (let
+ ((triple (+ (* b0 65536) (* (char-code (char-at s (+ i 1))) 256))))
+ (str
+ acc
+ (dr/b64-char (mod (quotient triple 262144) 64))
+ (dr/b64-char (mod (quotient triple 4096) 64))
+ (dr/b64-char (mod (quotient triple 64) 64))
+ "=")))
+ (else
+ (let
+ ((triple (* b0 65536)))
+ (str
+ acc
+ (dr/b64-char (mod (quotient triple 262144) 64))
+ (dr/b64-char (mod (quotient triple 4096) 64))
+ "=="))))))))
+
+(define
+ dream-base64-encode
+ (fn (s) (dr/b64-encode-loop s 0 (string-length s) "")))
+
+(define
+ dr/b64-decode-loop
+ (fn
+ (s i n acc)
+ (if
+ (>= i n)
+ acc
+ (let
+ ((p2 (char-at s (+ i 2)))
+ (p3 (char-at s (+ i 3))))
+ (let
+ ((c0 (dr/b64-index (char-at s i)))
+ (c1 (dr/b64-index (char-at s (+ i 1))))
+ (c2 (if (= p2 "=") 0 (dr/b64-index p2)))
+ (c3 (if (= p3 "=") 0 (dr/b64-index p3))))
+ (let
+ ((triple (+ (* c0 262144) (* c1 4096) (* c2 64) c3)))
+ (dr/b64-decode-loop
+ s
+ (+ i 4)
+ n
+ (str
+ acc
+ (char-from-code
+ (mod (quotient triple 65536) 256))
+ (if
+ (= p2 "=")
+ ""
+ (char-from-code
+ (mod (quotient triple 256) 256)))
+ (if (= p3 "=") "" (char-from-code (mod triple 256)))))))))))
+
+(define
+ dream-base64-decode
+ (fn
+ (s)
+ (if (= s "") "" (dr/b64-decode-loop s 0 (string-length s) ""))))
+
+;; ── Authorization header parsing ───────────────────────────────────
+(define dream-authorization (fn (req) (dream-header req "authorization")))
+
+(define
+ dream-bearer-token
+ (fn
+ (req)
+ (let
+ ((a (dream-authorization req)))
+ (if (and a (starts-with? a "Bearer ")) (substr a 7) nil))))
+
+(define
+ dream-basic-credentials
+ (fn
+ (req)
+ (let
+ ((a (dream-authorization req)))
+ (if
+ (and a (starts-with? a "Basic "))
+ (let
+ ((decoded (dream-base64-decode (substr a 6))))
+ (let
+ ((colon (index-of decoded ":")))
+ (if (< colon 0) nil {:pass (substr decoded (+ colon 1)) :user (substr decoded 0 colon)})))
+ nil))))
+
+;; ── Basic auth middleware ──────────────────────────────────────────
+;; check is (fn (user pass) -> bool). On success the request gains :dream-user.
+(define
+ dr/www-authenticate
+ (fn
+ (realm)
+ (dream-add-header
+ (dream-response 401 {:content-type "text/plain; charset=utf-8"} "Unauthorized")
+ "www-authenticate"
+ (str "Basic realm=\"" realm "\""))))
+
+(define
+ dream-basic-auth
+ (fn
+ (realm check)
+ (fn
+ (next)
+ (fn
+ (req)
+ (let
+ ((creds (dream-basic-credentials req)))
+ (if
+ (and creds (check (get creds :user) (get creds :pass)))
+ (next (assoc req :dream-user (get creds :user)))
+ (dr/www-authenticate realm)))))))
+
+(define dream-user (fn (req) (get req :dream-user)))
+
+;; ── Bearer-token middleware ────────────────────────────────────────
+;; check is (fn (token) -> principal | nil). On success the request gains
+;; :dream-principal. Missing/invalid -> 401.
+(define
+ dream-require-bearer
+ (fn
+ (check)
+ (fn
+ (next)
+ (fn
+ (req)
+ (let
+ ((tok (dream-bearer-token req)))
+ (let
+ ((principal (if tok (check tok) nil)))
+ (if
+ (nil? principal)
+ (dream-add-header
+ (dream-response 401 {:content-type "text/plain; charset=utf-8"} "Unauthorized")
+ "www-authenticate"
+ "Bearer")
+ (next (assoc req :dream-principal principal)))))))))
+
+(define dream-principal (fn (req) (get req :dream-principal)))
diff --git a/lib/dream/conformance.sh b/lib/dream/conformance.sh
new file mode 100644
index 00000000..bba0c225
--- /dev/null
+++ b/lib/dream/conformance.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+# dream-on-sx conformance runner — loads all dream modules + test suites in one
+# sx_server process and reports pass/fail per suite.
+#
+# Usage:
+# bash lib/dream/conformance.sh # run all suites
+# bash lib/dream/conformance.sh -v # verbose (list each suite)
+
+set -uo pipefail
+cd "$(git rev-parse --show-toplevel)"
+
+SX_SERVER="${SX_SERVER:-hosts/ocaml/_build/default/bin/sx_server.exe}"
+if [ ! -x "$SX_SERVER" ]; then
+ SX_SERVER="/root/rose-ash/hosts/ocaml/_build/default/bin/sx_server.exe"
+fi
+if [ ! -x "$SX_SERVER" ]; then
+ echo "ERROR: sx_server.exe not found." >&2
+ exit 1
+fi
+
+VERBOSE="${1:-}"
+
+# Dream library modules loaded before any test suite.
+MODULES=(
+ "lib/dream/types.sx"
+ "lib/dream/router.sx"
+ "lib/dream/middleware.sx"
+ "lib/dream/session.sx"
+ "lib/dream/flash.sx"
+ "lib/dream/form.sx"
+ "lib/dream/websocket.sx"
+ "lib/dream/static.sx"
+ "lib/dream/error.sx"
+ "lib/dream/cors.sx"
+ "lib/dream/json.sx"
+ "lib/dream/auth.sx"
+ "lib/dream/html.sx"
+ "lib/dream/headers.sx"
+ "lib/dream/run.sx"
+ "lib/dream/api.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
+SUITES=(
+ "types dream-ty-tests-run! lib/dream/tests/types.sx"
+ "router dream-rt-tests-run! lib/dream/tests/router.sx"
+ "middleware dream-mw-tests-run! lib/dream/tests/middleware.sx"
+ "session dream-ss-tests-run! lib/dream/tests/session.sx"
+ "flash dream-fl-tests-run! lib/dream/tests/flash.sx"
+ "form dream-fo-tests-run! lib/dream/tests/form.sx"
+ "websocket dream-ws-tests-run! lib/dream/tests/websocket.sx"
+ "static dream-st-tests-run! lib/dream/tests/static.sx"
+ "error dream-er-tests-run! lib/dream/tests/error.sx"
+ "cors dream-co-tests-run! lib/dream/tests/cors.sx"
+ "json dream-js-tests-run! lib/dream/tests/json.sx"
+ "auth dream-au-tests-run! lib/dream/tests/auth.sx"
+ "html dream-ht-tests-run! lib/dream/tests/html.sx"
+ "headers dream-hd-tests-run! lib/dream/tests/headers.sx"
+ "run dream-rn-tests-run! lib/dream/tests/run.sx"
+ "api dream-ap-tests-run! lib/dream/tests/api.sx"
+ "demos dream-dm-tests-run! lib/dream/tests/demos.sx"
+)
+
+TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT
+EPOCH=1
+emit_load () { echo "(epoch $EPOCH)"; echo "(load \"$1\")"; EPOCH=$((EPOCH+1)); }
+emit_eval () { echo "(epoch $EPOCH)"; echo "(eval \"$1\")"; EPOCH=$((EPOCH+1)); }
+
+{
+ for M in "${MODULES[@]}"; do emit_load "$M"; done
+ for SUITE in "${SUITES[@]}"; do
+ read -r _NAME _RUNNER FILE <<< "$SUITE"
+ emit_load "$FILE"
+ emit_eval "($_RUNNER)"
+ done
+} > "$TMPFILE"
+
+OUTPUT=$(timeout 540 "$SX_SERVER" < "$TMPFILE" 2>&1 || true)
+
+TOTAL_PASS=0
+TOTAL_FAIL=0
+FAILED_SUITES=()
+LAST_DICT_LINES=$(echo "$OUTPUT" | grep -E '^\{:' || true)
+
+I=0
+while read -r LINE; do
+ [ -z "$LINE" ] && continue
+ P=$(echo "$LINE" | grep -oE ':passed [0-9]+' | awk '{print $2}')
+ F=$(echo "$LINE" | grep -oE ':failed [0-9]+' | awk '{print $2}')
+ [ -z "$P" ] && P=0
+ [ -z "$F" ] && F=0
+ SUITE_INFO="${SUITES[$I]}"
+ SUITE_NAME=$(echo "$SUITE_INFO" | awk '{print $1}')
+ TOTAL_PASS=$((TOTAL_PASS + P))
+ TOTAL_FAIL=$((TOTAL_FAIL + F))
+ if [ "$F" -gt 0 ]; then
+ FAILED_SUITES+=("$SUITE_NAME: $P/$((P+F))")
+ printf 'X %-12s %d/%d\n' "$SUITE_NAME" "$P" "$((P+F))"
+ echo "$LINE" | grep -oE ':name "[^"]*"' | sed 's/:name / fail: /'
+ elif [ "$VERBOSE" = "-v" ]; then
+ printf 'ok %-12s %d passed\n' "$SUITE_NAME" "$P"
+ fi
+ I=$((I+1))
+done <<< "$LAST_DICT_LINES"
+
+TOTAL=$((TOTAL_PASS + TOTAL_FAIL))
+if [ "$TOTAL" -eq 0 ]; then
+ echo "ERROR: no suite results parsed. Raw output:" >&2
+ echo "$OUTPUT" >&2
+ exit 1
+fi
+if [ $TOTAL_FAIL -eq 0 ]; then
+ echo "ok $TOTAL_PASS/$TOTAL dream-on-sx tests passed (${#SUITES[@]} suites)"
+else
+ echo "FAIL $TOTAL_PASS/$TOTAL passed, $TOTAL_FAIL failed:"
+ for S in "${FAILED_SUITES[@]}"; do echo " $S"; done
+ exit 1
+fi
diff --git a/lib/dream/cors.sx b/lib/dream/cors.sx
new file mode 100644
index 00000000..c0d25756
--- /dev/null
+++ b/lib/dream/cors.sx
@@ -0,0 +1,51 @@
+;; lib/dream/cors.sx — Dream-on-SX CORS middleware.
+;; Decorates responses with Access-Control-Allow-* headers and short-circuits
+;; preflight OPTIONS requests with a 204. Depends on types.sx.
+
+(define dream-cors-defaults {:methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" :headers "Content-Type" :max-age 86400 :credentials false :origin "*"})
+
+(define
+ dr/cors-origin-headers
+ (fn
+ (opts resp)
+ (let
+ ((r1 (dream-add-header resp "access-control-allow-origin" (get opts :origin))))
+ (if
+ (get opts :credentials)
+ (dream-add-header r1 "access-control-allow-credentials" "true")
+ r1))))
+
+(define
+ dr/cors-preflight
+ (fn
+ (opts)
+ (dr/cors-origin-headers
+ opts
+ (dream-add-header
+ (dream-add-header
+ (dream-add-header
+ (dream-empty 204)
+ "access-control-allow-methods"
+ (get opts :methods))
+ "access-control-allow-headers"
+ (get opts :headers))
+ "access-control-max-age"
+ (str (get opts :max-age))))))
+
+(define
+ dream-cors-with
+ (fn
+ (opts)
+ (fn
+ (next)
+ (fn
+ (req)
+ (if
+ (= (dream-method req) "OPTIONS")
+ (dr/cors-preflight opts)
+ (dr/cors-origin-headers opts (next req)))))))
+
+(define dream-cors (dream-cors-with dream-cors-defaults))
+(define
+ dream-cors-origin
+ (fn (origin) (dream-cors-with (assoc dream-cors-defaults :origin origin))))
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/counter.sx b/lib/dream/demos/counter.sx
new file mode 100644
index 00000000..4166d7f7
--- /dev/null
+++ b/lib/dream/demos/counter.sx
@@ -0,0 +1,35 @@
+;; lib/dream/demos/counter.sx — per-session visit counter (counter.ml).
+;; Demonstrates the session middleware: each browser session keeps its own count.
+
+(define
+ dream-counter-handler
+ (fn
+ (req)
+ (let
+ ((n (+ 1 (or (dream-session-field req "count") 0))))
+ (begin
+ (dream-set-session-field req "count" n)
+ (dream-html (str "You have visited this page " n " time(s).
"))))))
+
+;; reset clears the session counter
+(define
+ dream-counter-reset
+ (fn
+ (req)
+ (begin
+ (dream-set-session-field req "count" 0)
+ (dream-redirect "/"))))
+
+(define
+ dream-counter-app-with
+ (fn
+ (backend)
+ ((dream-sessions backend)
+ (dream-router
+ (list
+ (dream-get "/" dream-counter-handler)
+ (dream-post "/reset" dream-counter-reset))))))
+
+(define dream-counter-app (dream-counter-app-with (dream-memory-sessions)))
+
+;; entry point: (dream-run (dream-counter-app-with (dream-memory-sessions)))
diff --git a/lib/dream/demos/hello.sx b/lib/dream/demos/hello.sx
new file mode 100644
index 00000000..0082dc25
--- /dev/null
+++ b/lib/dream/demos/hello.sx
@@ -0,0 +1,16 @@
+;; lib/dream/demos/hello.sx — the canonical Dream "Hello, World!" (hello.ml).
+;; Dream.run (Dream.router [Dream.get "/" (fun _ -> Dream.html "Hello!")]).
+
+(define
+ dream-hello-app
+ (dream-router
+ (list
+ (dream-get "/" (fn (req) (dream-html "Hello, World!
")))
+ (dream-get
+ "/hello/:name"
+ (fn
+ (req)
+ (dream-html (str "Hello, " (dream-param req "name") "!
")))))))
+
+;; entry point (installs the handler on the host):
+;; (dream-run dream-hello-app)
diff --git a/lib/dream/demos/todo.sx b/lib/dream/demos/todo.sx
new file mode 100644
index 00000000..ab367199
--- /dev/null
+++ b/lib/dream/demos/todo.sx
@@ -0,0 +1,96 @@
+;; 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. User text is HTML-escaped on render (dream-escape).
+;; 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] " "[ ] ")
+ (dream-escape (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/error.sx b/lib/dream/error.sx
new file mode 100644
index 00000000..9f1d3174
--- /dev/null
+++ b/lib/dream/error.sx
@@ -0,0 +1,41 @@
+;; lib/dream/error.sx — Dream-on-SX status phrases + error-handling middleware.
+;; dream-catch wraps a handler and turns a raised error into a 500 response (or a
+;; custom page). Depends on types.sx.
+
+;; ── status reason phrases ──────────────────────────────────────────
+(define dr/status-texts {:206 "Partial Content" :202 "Accepted" :422 "Unprocessable Entity" :400 "Bad Request" :302 "Found" :204 "No Content" :502 "Bad Gateway" :429 "Too Many Requests" :301 "Moved Permanently" :415 "Unsupported Media Type" :405 "Method Not Allowed" :303 "See Other" :401 "Unauthorized" :304 "Not Modified" :503 "Service Unavailable" :404 "Not Found" :308 "Permanent Redirect" :504 "Gateway Timeout" :416 "Range Not Satisfiable" :500 "Internal Server Error" :307 "Temporary Redirect" :201 "Created" :501 "Not Implemented" :409 "Conflict" :200 "OK" :410 "Gone" :403 "Forbidden"})
+
+(define
+ dream-status-text
+ (fn (status) (or (get dr/status-texts (str status)) "Unknown")))
+(define
+ dream-status-line
+ (fn (status) (str status " " (dream-status-text status))))
+
+;; ── error-handling middleware ──────────────────────────────────────
+(define
+ dream-default-error-page
+ (fn
+ (req e)
+ (dream-html-status
+ 500
+ (str "" (dream-status-line 500) "
"))))
+
+(define
+ dream-catch-with
+ (fn
+ (on-error)
+ (fn
+ (next)
+ (fn (req) (guard (e (true (on-error req e))) (next req))))))
+
+(define dream-catch (dream-catch-with dream-default-error-page))
+
+;; a fallback handler that renders a status page for any code
+(define
+ dream-status-page
+ (fn
+ (status)
+ (dream-html-status
+ status
+ (str "" (dream-status-line status) "
"))))
diff --git a/lib/dream/flash.sx b/lib/dream/flash.sx
new file mode 100644
index 00000000..c026fdf6
--- /dev/null
+++ b/lib/dream/flash.sx
@@ -0,0 +1,91 @@
+;; lib/dream/flash.sx — Dream-on-SX flash messages.
+;; A single-request cookie store: messages added during one request are read on
+;; the NEXT request, then the cookie is cleared. Depends on types.sx + session.sx
+;; (shared cookie helpers). A message is {:category c :message m}.
+
+;; ── cookie codec ───────────────────────────────────────────────────
+;; escape the field separators so categories/messages round-trip safely
+(define
+ dr/flash-esc
+ (fn (s) (replace (replace (replace s "%" "%25") "|" "%7C") "~" "%7E")))
+(define
+ dr/flash-unesc
+ (fn (s) (replace (replace (replace s "%7E" "~") "%7C" "|") "%25" "%")))
+
+(define
+ dr/flash-encode
+ (fn
+ (msgs)
+ (join
+ "~"
+ (map
+ (fn
+ (m)
+ (str
+ (dr/flash-esc (get m :category))
+ "|"
+ (dr/flash-esc (get m :message))))
+ msgs))))
+
+(define
+ dr/flash-decode
+ (fn
+ (s)
+ (if
+ (= s "")
+ (list)
+ (map
+ (fn (part) (let ((i (index-of part "|"))) {:message (dr/flash-unesc (substr part (+ i 1))) :category (dr/flash-unesc (substr part 0 i))}))
+ (split s "~")))))
+
+;; ── mutable outbox cell ────────────────────────────────────────────
+(define dr/flash-box (fn () (let ((items (list))) {:add (fn (x) (set! items (concat items (list x)))) :get (fn () items)})))
+
+;; ── middleware ─────────────────────────────────────────────────────
+(define dream-flash-cookie-name "dream.flash")
+
+(define
+ dream-flash
+ (fn
+ (next)
+ (fn
+ (req)
+ (let
+ ((incoming (dr/flash-decode (or (dream-cookie req dream-flash-cookie-name) "")))
+ (box (dr/flash-box)))
+ (let
+ ((resp (next (assoc req :dream-flash {:box box :incoming incoming}))))
+ (let
+ ((out ((get box :get))))
+ (cond
+ ((not (empty? out))
+ (dream-set-cookie
+ resp
+ dream-flash-cookie-name
+ (dr/flash-encode out)
+ {:path "/" :http-only true :same-site "Lax"}))
+ ((not (empty? incoming))
+ (dream-drop-cookie resp dream-flash-cookie-name))
+ (else resp))))))))
+
+;; ── handler-facing API ─────────────────────────────────────────────
+(define
+ dream-add-flash-message
+ (fn
+ (req category msg)
+ (begin ((get (get (get req :dream-flash) :box) :add) {:message msg :category category}) req)))
+
+(define
+ dream-flash-messages
+ (fn (req) (get (get req :dream-flash) :incoming)))
+(define dream-flash-category (fn (m) (get m :category)))
+(define dream-flash-message (fn (m) (get m :message)))
+
+;; convenience: only messages of a given category
+(define
+ dream-flash-of
+ (fn
+ (req category)
+ (filter
+ (fn (m) (= (get m :category) category))
+ (dream-flash-messages req))))
diff --git a/lib/dream/form.sx b/lib/dream/form.sx
new file mode 100644
index 00000000..1593b698
--- /dev/null
+++ b/lib/dream/form.sx
@@ -0,0 +1,366 @@
+;; lib/dream/form.sx — Dream-on-SX forms + CSRF.
+;; Parses application/x-www-form-urlencoded bodies; CSRF tokens are stateless,
+;; signed, and session-scoped. The signing function is injectable (a pure-SX keyed
+;; hash by default — production should swap in a host HMAC). Depends on types.sx +
+;; session.sx. dream-form returns an Ok/Err result value.
+
+;; ── Result (Ok/Err) ────────────────────────────────────────────────
+(define dream-ok (fn (v) {:value v :result "ok"}))
+(define dream-err (fn (r) {:reason r :result "err"}))
+(define dream-ok? (fn (x) (= (get x :result) "ok")))
+(define dream-err? (fn (x) (= (get x :result) "err")))
+(define dream-ok-value (fn (x) (get x :value)))
+(define dream-err-reason (fn (x) (get x :reason)))
+
+;; ── percent decoding ───────────────────────────────────────────────
+(define
+ dr/hex-digit
+ (fn
+ (c)
+ (let
+ ((n (char-code c)))
+ (cond
+ ((and (>= n 48) (<= n 57)) (- n 48))
+ ((and (>= n 65) (<= n 70))
+ (+ 10 (- n 65)))
+ ((and (>= n 97) (<= n 102))
+ (+ 10 (- n 97)))
+ (else 0)))))
+
+(define
+ dr/url-decode-loop
+ (fn
+ (s i n acc)
+ (if
+ (>= i n)
+ acc
+ (let
+ ((c (char-at s i)))
+ (if
+ (and (= c "%") (< (+ i 2) n))
+ (dr/url-decode-loop
+ s
+ (+ i 3)
+ n
+ (str
+ acc
+ (char-from-code
+ (+
+ (* 16 (dr/hex-digit (char-at s (+ i 1))))
+ (dr/hex-digit (char-at s (+ i 2)))))))
+ (dr/url-decode-loop s (+ i 1) n (str acc c)))))))
+
+(define
+ dr/url-decode
+ (fn
+ (s)
+ (let
+ ((s2 (replace s "+" " ")))
+ (dr/url-decode-loop s2 0 (string-length s2) ""))))
+
+;; ── substring splitter (split primitive is char-class based) ───────
+(define
+ dr/split-on
+ (fn
+ (s sep)
+ (let
+ ((i (index-of s sep)))
+ (if
+ (< i 0)
+ (list s)
+ (cons
+ (substr s 0 i)
+ (dr/split-on (substr s (+ i (string-length sep))) sep))))))
+
+;; ── urlencoded body parsing ────────────────────────────────────────
+(define
+ dr/parse-form-body
+ (fn
+ (body)
+ (if
+ (= body "")
+ {}
+ (reduce
+ (fn
+ (acc pair)
+ (if
+ (= pair "")
+ acc
+ (let
+ ((j (index-of pair "=")))
+ (if
+ (< j 0)
+ (assoc acc (dr/url-decode pair) "")
+ (assoc
+ acc
+ (dr/url-decode (substr pair 0 j))
+ (dr/url-decode (substr pair (+ j 1))))))))
+ {}
+ (split body "&")))))
+
+;; raw fields, no CSRF check
+(define dream-form-fields (fn (req) (dr/parse-form-body (dream-body req))))
+(define
+ dream-form-field
+ (fn (req name) (get (dream-form-fields req) name)))
+
+;; ── CSRF signing (injectable; pure-SX keyed hash default) ──────────
+(define
+ dr/poly-hash
+ (fn (s base seed) (dr/poly-loop s 0 (string-length s) seed base)))
+(define
+ dr/poly-loop
+ (fn
+ (s i n h base)
+ (if
+ (>= i n)
+ h
+ (dr/poly-loop
+ s
+ (+ i 1)
+ n
+ (mod (+ (* h base) (char-code (char-at s i))) 2147483647)
+ base))))
+
+;; NOTE: not cryptographic — adequate to demonstrate stateless CSRF; production
+;; should inject a real HMAC via dream-csrf-with.
+(define
+ dream-csrf-sign-default
+ (fn
+ (secret msg)
+ (let
+ ((m (str secret "|" msg)))
+ (str
+ (dr/poly-hash m 131 7)
+ "-"
+ (dr/poly-hash m 137 13)))))
+
+(define dream-csrf-field-name "dream.csrf")
+
+(define
+ dr/csrf-make-token
+ (fn (sign secret sid) (str sid "." (sign secret sid))))
+
+(define
+ dr/csrf-valid?
+ (fn
+ (sign secret sid token)
+ (if
+ (or (nil? token) (= token ""))
+ false
+ (let
+ ((dot (index-of token ".")))
+ (if
+ (< dot 0)
+ false
+ (let
+ ((tsid (substr token 0 dot))
+ (tsig (substr token (+ dot 1))))
+ (and (= tsid sid) (= tsig (sign secret sid)))))))))
+
+;; ── CSRF middleware: attach signing context (needs session upstream) ──
+(define
+ dream-csrf-with
+ (fn
+ (secret sign)
+ (fn (next) (fn (req) (next (assoc req :dream-csrf {:sign sign :sid (dream-session-id req) :secret secret}))))))
+
+(define
+ dream-csrf
+ (fn (secret) (dream-csrf-with secret dream-csrf-sign-default)))
+
+(define dr/csrf-of (fn (req) (get req :dream-csrf)))
+
+;; current token + hidden-input tag for templates
+(define
+ dream-csrf-token
+ (fn
+ (req)
+ (let
+ ((c (dr/csrf-of req)))
+ (dr/csrf-make-token (get c :sign) (get c :secret) (get c :sid)))))
+
+(define
+ dream-csrf-tag
+ (fn
+ (req)
+ (str
+ "")))
+
+;; ── dream-form: parse + verify CSRF -> Ok fields | Err reason ──────
+(define
+ dream-form
+ (fn
+ (req)
+ (let
+ ((c (dr/csrf-of req)))
+ (if
+ (nil? c)
+ (dream-err :csrf-context-missing)
+ (let
+ ((fields (dream-form-fields req)))
+ (if
+ (dr/csrf-valid?
+ (get c :sign)
+ (get c :secret)
+ (get c :sid)
+ (get fields dream-csrf-field-name))
+ (dream-ok fields)
+ (dream-err :csrf-token-invalid)))))))
+
+;; ── CSRF auto-rejecting middleware (unsafe methods need a valid token) ──
+(define
+ dr/csrf-safe-method?
+ (fn (m) (or (= m "GET") (= m "HEAD") (= m "OPTIONS"))))
+
+(define
+ dream-csrf-protect-with
+ (fn
+ (secret sign)
+ (fn
+ (next)
+ (fn
+ (req)
+ (let
+ ((req2 (assoc req :dream-csrf {:sign sign :sid (dream-session-id req) :secret secret})))
+ (if
+ (dr/csrf-safe-method? (dream-method req2))
+ (next req2)
+ (let
+ ((token (get (dream-form-fields req2) dream-csrf-field-name)))
+ (if
+ (dr/csrf-valid? sign secret (dream-session-id req2) token)
+ (next req2)
+ (dream-html-status 403 "CSRF token invalid")))))))))
+
+(define
+ dream-csrf-protect
+ (fn (secret) (dream-csrf-protect-with secret dream-csrf-sign-default)))
+
+;; ── multipart/form-data parsing ────────────────────────────────────
+;; In-memory (not yet streaming): parses the whole body into parts, each
+;; {:name :filename :content-type :content}. Returns Ok parts | Err :not-multipart.
+(define
+ dr/multipart-boundary
+ (fn
+ (ctype)
+ (let
+ ((i (index-of ctype "boundary=")))
+ (if
+ (< i 0)
+ ""
+ (let
+ ((raw (trim (substr ctype (+ i 9)))))
+ (if
+ (starts-with? raw "\"")
+ (substr raw 1 (- (string-length raw) 2))
+ raw))))))
+
+;; strip one leading and one trailing CRLF
+(define
+ dr/strip-edges
+ (fn
+ (s)
+ (let
+ ((s1 (if (starts-with? s "\r\n") (substr s 2) s)))
+ (if
+ (ends-with? s1 "\r\n")
+ (substr s1 0 (- (string-length s1) 2))
+ s1))))
+
+;; value of attr="..." within a header block
+(define
+ dr/cd-attr
+ (fn
+ (block attr)
+ (let
+ ((key (str attr "=\"")))
+ (let
+ ((i (index-of block key)))
+ (if
+ (< i 0)
+ nil
+ (let
+ ((rest (substr block (+ i (string-length key)))))
+ (substr rest 0 (index-of rest "\""))))))))
+
+;; value of a named header line within a header block
+(define
+ dr/block-header
+ (fn
+ (block name)
+ (reduce
+ (fn
+ (acc line)
+ (if
+ (and
+ (nil? acc)
+ (starts-with? (lower line) (str (lower name) ":")))
+ (trim (substr line (+ (index-of line ":") 1)))
+ acc))
+ nil
+ (dr/split-on block "\r\n"))))
+
+(define
+ dr/parse-part
+ (fn
+ (seg)
+ (let
+ ((s (dr/strip-edges seg)))
+ (let
+ ((sp (index-of s "\r\n\r\n")))
+ (if
+ (< sp 0)
+ nil
+ (let
+ ((block (substr s 0 sp))
+ (content (substr s (+ sp 4))))
+ {:name (dr/cd-attr block "name") :filename (dr/cd-attr block "filename") :content-type (dr/block-header block "content-type") :content content}))))))
+
+(define
+ dream-multipart
+ (fn
+ (req)
+ (let
+ ((boundary (dr/multipart-boundary (or (dream-header req "content-type") ""))))
+ (if
+ (= boundary "")
+ (dream-err :not-multipart)
+ (let
+ ((segs (dr/split-on (dream-body req) (str "--" boundary))))
+ (dream-ok
+ (filter
+ (fn (p) (not (nil? p)))
+ (map
+ dr/parse-part
+ (filter (fn (seg) (starts-with? seg "\r\n")) segs)))))))))
+
+;; accessors over a parts list
+(define
+ dream-multipart-field
+ (fn
+ (parts name)
+ (reduce
+ (fn
+ (acc p)
+ (if (and (nil? acc) (= (get p :name) name)) (get p :content) acc))
+ nil
+ parts)))
+
+(define
+ dream-multipart-file
+ (fn
+ (parts name)
+ (reduce
+ (fn
+ (acc p)
+ (if
+ (and (nil? acc) (= (get p :name) name) (get p :filename))
+ p
+ acc))
+ nil
+ parts)))
diff --git a/lib/dream/headers.sx b/lib/dream/headers.sx
new file mode 100644
index 00000000..2d1ec4d3
--- /dev/null
+++ b/lib/dream/headers.sx
@@ -0,0 +1,54 @@
+;; lib/dream/headers.sx — Dream-on-SX security headers + cache-control helpers.
+;; Depends on types.sx.
+
+;; ── security headers middleware ────────────────────────────────────
+(define dream-security-defaults {:x-frame-options "DENY" :referrer-policy "no-referrer" :x-content-type-options "nosniff" :hsts false})
+
+(define
+ dr/apply-security
+ (fn
+ (opts resp)
+ (let
+ ((r1 (dream-add-header (dream-add-header (dream-add-header resp "x-content-type-options" (get opts :x-content-type-options)) "x-frame-options" (get opts :x-frame-options)) "referrer-policy" (get opts :referrer-policy))))
+ (if
+ (get opts :hsts)
+ (dream-add-header
+ r1
+ "strict-transport-security"
+ "max-age=31536000; includeSubDomains")
+ r1))))
+
+(define
+ dream-security-headers-with
+ (fn (opts) (fn (next) (fn (req) (dr/apply-security opts (next req))))))
+(define
+ dream-security-headers
+ (dream-security-headers-with dream-security-defaults))
+
+;; ── cache-control response helpers ─────────────────────────────────
+(define
+ dream-cache
+ (fn
+ (resp seconds)
+ (dream-add-header resp "cache-control" (str "public, max-age=" seconds))))
+(define
+ dream-private-cache
+ (fn
+ (resp seconds)
+ (dream-add-header resp "cache-control" (str "private, max-age=" seconds))))
+(define
+ dream-no-store
+ (fn (resp) (dream-add-header resp "cache-control" "no-store")))
+(define
+ dream-no-cache
+ (fn
+ (resp)
+ (dream-add-header
+ resp
+ "cache-control"
+ "no-cache, no-store, must-revalidate")))
+
+;; cache-control middleware: stamp a max-age on every response
+(define
+ dream-cache-for
+ (fn (seconds) (fn (next) (fn (req) (dream-cache (next req) seconds)))))
diff --git a/lib/dream/html.sx b/lib/dream/html.sx
new file mode 100644
index 00000000..feeec7a8
--- /dev/null
+++ b/lib/dream/html.sx
@@ -0,0 +1,24 @@
+;; lib/dream/html.sx — Dream-on-SX HTML escaping for safe templating.
+;; Interpolating user input into HTML without escaping is an XSS hole; dream-escape
+;; neutralises it. Depends on nothing (pure string ops).
+
+;; escape text for HTML element content / double-quoted attributes
+(define
+ dream-escape
+ (fn
+ (s)
+ (replace
+ (replace
+ (replace (replace (replace s "&" "&") "<" "<") ">" ">")
+ "\""
+ """)
+ "'"
+ "'")))
+
+;; build a single attribute: name="escaped-value"
+(define dream-attr (fn (name val) (str name "=\"" (dream-escape val) "\"")))
+
+;; join escaped text with a separator, escaping each piece
+(define
+ dream-escape-join
+ (fn (sep pieces) (join sep (map dream-escape pieces))))
diff --git a/lib/dream/json.sx b/lib/dream/json.sx
new file mode 100644
index 00000000..92d6cadc
--- /dev/null
+++ b/lib/dream/json.sx
@@ -0,0 +1,183 @@
+;; lib/dream/json.sx — Dream-on-SX JSON encode/parse (pure SX).
+;; The host JSON primitives live in the ocaml-on-sx runtime, not the base env, so
+;; Dream ships its own. Depends on types.sx. (number? is unreliable in this env —
+;; type-of "number" is used instead.)
+
+;; ── encoding ───────────────────────────────────────────────────────
+(define
+ dr/json-escape
+ (fn
+ (s)
+ (replace
+ (replace
+ (replace (replace (replace s "\\" "\\\\") "\"" "\\\"") "\n" "\\n")
+ "\r"
+ "\\r")
+ "\t"
+ "\\t")))
+(define dr/json-quote (fn (s) (str "\"" (dr/json-escape s) "\"")))
+
+(define
+ dream-json-encode
+ (fn
+ (v)
+ (cond
+ ((nil? v) "null")
+ ((boolean? v) (if v "true" "false"))
+ ((= (type-of v) "number") (str v))
+ ((string? v) (dr/json-quote v))
+ ((list? v) (str "[" (join "," (map dream-json-encode v)) "]"))
+ ((dict? v)
+ (str
+ "{"
+ (join
+ ","
+ (map
+ (fn
+ (k)
+ (str (dr/json-quote k) ":" (dream-json-encode (get v k))))
+ (keys v)))
+ "}"))
+ (else (dr/json-quote (str v))))))
+
+;; ── parsing (recursive descent; returns {:val :pos}) ───────────────
+(define
+ dr/json-space?
+ (fn (c) (or (= c " ") (= c "\n") (= c "\r") (= c "\t"))))
+(define
+ dr/json-ws
+ (fn
+ (s i)
+ (if
+ (and (< i (string-length s)) (dr/json-space? (char-at s i)))
+ (dr/json-ws s (+ i 1))
+ i)))
+
+(define
+ dr/json-digit?
+ (fn
+ (c)
+ (let ((n (char-code c))) (and (>= n 48) (<= n 57)))))
+(define
+ dr/json-num-char?
+ (fn
+ (c)
+ (or
+ (dr/json-digit? c)
+ (= c "-")
+ (= c "+")
+ (= c ".")
+ (= c "e")
+ (= c "E"))))
+(define
+ dr/json-num-end
+ (fn
+ (s i)
+ (if
+ (and (< i (string-length s)) (dr/json-num-char? (char-at s i)))
+ (dr/json-num-end s (+ i 1))
+ i)))
+(define
+ dr/json-to-number
+ (fn
+ (str-val)
+ (if
+ (or
+ (contains? str-val ".")
+ (contains? str-val "e")
+ (contains? str-val "E"))
+ (parse-float str-val)
+ (parse-int str-val))))
+
+(define
+ dr/json-str
+ (fn
+ (s i acc)
+ (let
+ ((c (char-at s i)))
+ (cond
+ ((= c "\"") {:val acc :pos (+ i 1)})
+ ((= c "\\")
+ (let
+ ((e (char-at s (+ i 1))))
+ (cond
+ ((= e "n") (dr/json-str s (+ i 2) (str acc "\n")))
+ ((= e "r") (dr/json-str s (+ i 2) (str acc "\r")))
+ ((= e "t") (dr/json-str s (+ i 2) (str acc "\t")))
+ (else (dr/json-str s (+ i 2) (str acc e))))))
+ (else (dr/json-str s (+ i 1) (str acc c)))))))
+
+(define
+ dr/json-num
+ (fn (s i) (let ((j (dr/json-num-end s i))) {:val (dr/json-to-number (substr s i (- j i))) :pos j})))
+
+(define
+ dr/json-arr
+ (fn
+ (s i acc)
+ (let
+ ((i (dr/json-ws s i)))
+ (if
+ (= (char-at s i) "]")
+ {:val acc :pos (+ i 1)}
+ (let
+ ((r (dr/json-val s i)))
+ (let
+ ((i2 (dr/json-ws s (get r :pos))))
+ (if
+ (= (char-at s i2) ",")
+ (dr/json-arr
+ s
+ (+ i2 1)
+ (concat acc (list (get r :val))))
+ {:val (concat acc (list (get r :val))) :pos (+ i2 1)})))))))
+
+(define
+ dr/json-obj
+ (fn
+ (s i acc)
+ (let
+ ((i (dr/json-ws s i)))
+ (if
+ (= (char-at s i) "}")
+ {:val acc :pos (+ i 1)}
+ (let
+ ((kr (dr/json-str s (+ i 1) "")))
+ (let
+ ((i2 (dr/json-ws s (get kr :pos))))
+ (let
+ ((vr (dr/json-val s (+ i2 1))))
+ (let
+ ((i3 (dr/json-ws s (get vr :pos))))
+ (if
+ (= (char-at s i3) ",")
+ (dr/json-obj
+ s
+ (+ i3 1)
+ (assoc acc (get kr :val) (get vr :val)))
+ {:val (assoc acc (get kr :val) (get vr :val)) :pos (+ i3 1)})))))))))
+
+(define
+ dr/json-val
+ (fn
+ (s i)
+ (let
+ ((i (dr/json-ws s i)))
+ (let
+ ((c (char-at s i)))
+ (cond
+ ((= c "{") (dr/json-obj s (+ i 1) {}))
+ ((= c "[") (dr/json-arr s (+ i 1) (list)))
+ ((= c "\"") (dr/json-str s (+ i 1) ""))
+ ((= c "t") {:val true :pos (+ i 4)})
+ ((= c "f") {:val false :pos (+ i 5)})
+ ((= c "n") {:val nil :pos (+ i 4)})
+ (else (dr/json-num s i)))))))
+
+(define dream-json-parse (fn (s) (get (dr/json-val s 0) :val)))
+
+;; ── responses ──────────────────────────────────────────────────────
+;; encode a value into a JSON response (dream-json takes a raw string body)
+(define dream-json-value (fn (v) (dream-json (dream-json-encode v))))
+;; read + parse the request body as JSON
+(define dream-json-body (fn (req) (dream-json-parse (dream-body req))))
diff --git a/lib/dream/middleware.sx b/lib/dream/middleware.sx
new file mode 100644
index 00000000..9a980a90
--- /dev/null
+++ b/lib/dream/middleware.sx
@@ -0,0 +1,92 @@
+;; lib/dream/middleware.sx — Dream-on-SX middleware.
+;; A middleware is handler->handler. Composition is plain function composition:
+;; m1 @@ m2 @@ handler = (m1 (m2 handler)). Depends on types.sx + router.sx
+;; (reuses dr/apply-middlewares for the fold).
+
+;; ── composition ────────────────────────────────────────────────────
+;; (dream-pipeline (list m1 m2 m3) handler) = (m1 (m2 (m3 handler))).
+(define
+ dream-pipeline
+ (fn (middlewares handler) (dr/apply-middlewares middlewares handler)))
+
+;; identity middleware
+(define dream-no-middleware (fn (next) next))
+
+;; ── logger ─────────────────────────────────────────────────────────
+;; Parameterised on a clock and a sink so it is testable without IO.
+;; sink receives {:method :path :status :elapsed}.
+(define
+ dream-logger-with
+ (fn
+ (clock sink)
+ (fn
+ (next)
+ (fn
+ (req)
+ (let
+ ((t0 (clock)))
+ (let ((resp (next req))) (begin (sink {:path (dream-path req) :status (dream-status resp) :method (dream-method req) :elapsed (- (clock) t0)}) resp)))))))
+
+;; default logger performs host effects for the clock and the log sink
+(define
+ dream-logger
+ (dream-logger-with
+ (fn () (perform (:dream-clock)))
+ (fn (entry) (perform (:dream-log entry)))))
+
+;; format a log entry as a one-line string (apache-ish)
+(define
+ dream-log-line
+ (fn
+ (entry)
+ (str
+ (get entry :method)
+ " "
+ (get entry :path)
+ " -> "
+ (get entry :status)
+ " ("
+ (get entry :elapsed)
+ "ms)")))
+
+;; ── content-type sniffer ───────────────────────────────────────────
+(define
+ dr/sniff-content-type
+ (fn
+ (body)
+ (cond
+ ((= body "") "text/plain; charset=utf-8")
+ ((starts-with? body "<") "text/html; charset=utf-8")
+ ((starts-with? body "{") "application/json")
+ ((starts-with? body "[") "application/json")
+ (else "text/plain; charset=utf-8"))))
+
+;; sets Content-Type from the body only when the handler left it unset
+(define
+ dream-content-type
+ (fn
+ (next)
+ (fn
+ (req)
+ (let
+ ((resp (next req)))
+ (if
+ (dream-resp-header resp "content-type")
+ resp
+ (dream-add-header
+ resp
+ "content-type"
+ (dr/sniff-content-type (dream-resp-body resp))))))))
+
+;; ── small reusable middlewares ─────────────────────────────────────
+;; always attach a response header
+(define
+ dream-set-header
+ (fn
+ (name val)
+ (fn (next) (fn (req) (dream-add-header (next req) name val)))))
+
+;; rewrite/observe the request before the handler sees it
+(define
+ dream-tap-request
+ (fn (f) (fn (next) (fn (req) (next (f req))))))
diff --git a/lib/dream/router.sx b/lib/dream/router.sx
new file mode 100644
index 00000000..9158ee7d
--- /dev/null
+++ b/lib/dream/router.sx
@@ -0,0 +1,170 @@
+;; lib/dream/router.sx — Dream-on-SX routing.
+;; Routes are dicts {:method :path :handler}; a router is a handler that
+;; dispatches request -> response by method + path, extracting :name path
+;; params and binding a ** catch-all. No path match -> 404; path matches but
+;; method doesn't -> 405 + Allow. HEAD falls back to the GET handler with an
+;; empty body. Depends on types.sx.
+
+;; ── route constructors (one per HTTP method) ───────────────────────
+(define dream-get (fn (path handler) (dream-route "GET" path handler)))
+(define dream-post (fn (path handler) (dream-route "POST" path handler)))
+(define dream-put (fn (path handler) (dream-route "PUT" path handler)))
+(define
+ dream-delete
+ (fn (path handler) (dream-route "DELETE" path handler)))
+(define dream-patch (fn (path handler) (dream-route "PATCH" path handler)))
+(define dream-head (fn (path handler) (dream-route "HEAD" path handler)))
+(define
+ dream-options
+ (fn (path handler) (dream-route "OPTIONS" path handler)))
+(define dream-any (fn (path handler) (dream-route "ANY" path handler)))
+
+;; ── path segmentation ──────────────────────────────────────────────
+;; "/users/42/" -> ("users" "42"); "/" -> ()
+(define
+ dr/segs
+ (fn (path) (filter (fn (s) (not (= s ""))) (split path "/"))))
+
+(define
+ dr/join-path
+ (fn
+ (prefix path)
+ (str "/" (join "/" (concat (dr/segs prefix) (dr/segs path))))))
+
+;; ── segment matching ───────────────────────────────────────────────
+;; Returns a params dict on match (possibly empty {}), nil on no match.
+(define
+ dr/match-segs
+ (fn
+ (pat path params)
+ (cond
+ ((and (empty? pat) (empty? path)) params)
+ ((empty? pat) nil)
+ (else
+ (let
+ ((ps (first pat)))
+ (cond
+ ((= ps "**") (assoc params "**" (join "/" path)))
+ ((empty? path) nil)
+ ((starts-with? ps ":")
+ (dr/match-segs
+ (rest pat)
+ (rest path)
+ (assoc params (substr ps 1) (first path))))
+ ((= ps (first path))
+ (dr/match-segs (rest pat) (rest path) params))
+ (else nil)))))))
+
+;; path-only match: returns params dict or nil
+(define
+ dr/route-params
+ (fn
+ (r req)
+ (dr/match-segs
+ (dr/segs (dream-route-path r))
+ (dr/segs (dream-path req))
+ {})))
+
+;; method acceptance: exact, ANY, or HEAD served by a GET route
+(define
+ dr/method-accepts?
+ (fn
+ (route-method req-method)
+ (or
+ (= route-method "ANY")
+ (= route-method req-method)
+ (and (= req-method "HEAD") (= route-method "GET")))))
+
+;; ── middleware pipeline (shared with middleware.sx) ────────────────
+;; m1 @@ m2 @@ handler = (m1 (m2 handler)); first in list is outermost.
+(define
+ dr/apply-middlewares
+ (fn (mws handler) (reduce (fn (h mw) (mw h)) handler (reverse mws))))
+
+;; ── scope: prefix mount + middleware chain ─────────────────────────
+;; Returns a flat list of routes; nested scopes flatten correctly.
+(define
+ dr/flatten-routes
+ (fn
+ (items)
+ (reduce
+ (fn
+ (acc it)
+ (if
+ (dream-route? it)
+ (concat acc (list it))
+ (concat acc (dr/flatten-routes it))))
+ (list)
+ items)))
+
+(define
+ dream-scope
+ (fn
+ (prefix middlewares routes)
+ (map
+ (fn
+ (r)
+ (dream-route
+ (dream-route-method r)
+ (dr/join-path prefix (dream-route-path r))
+ (dr/apply-middlewares middlewares (dream-route-handler r))))
+ (dr/flatten-routes routes))))
+
+;; ── dispatch ───────────────────────────────────────────────────────
+;; allowed = methods of routes whose PATH matched (for 405 + Allow).
+(define
+ dr/dispatch
+ (fn
+ (routes req allowed)
+ (if
+ (empty? routes)
+ (if
+ (empty? allowed)
+ (dream-not-found)
+ (dream-method-not-allowed allowed))
+ (let
+ ((r (first routes)))
+ (let
+ ((params (dr/route-params r req)))
+ (if
+ (nil? params)
+ (dr/dispatch (rest routes) req allowed)
+ (if
+ (dr/method-accepts? (dream-route-method r) (dream-method req))
+ (dr/run-route r req params)
+ (dr/dispatch
+ (rest routes)
+ req
+ (concat allowed (list (dream-route-method r)))))))))))
+
+;; run a matched route; blank the body for an auto-HEAD on a GET route
+(define
+ dr/run-route
+ (fn
+ (r req params)
+ (let
+ ((resp (dream-coerce-response ((dream-route-handler r) (dream-with-params req params)))))
+ (if
+ (and
+ (= (dream-method req) "HEAD")
+ (not (= (dream-route-method r) "HEAD")))
+ (dream-response (dream-status resp) (dream-headers resp) "")
+ resp))))
+
+;; 405 response with an Allow header listing the path's methods
+(define
+ dream-method-not-allowed
+ (fn
+ (allowed)
+ (dream-add-header
+ (dream-response 405 {:content-type "text/plain; charset=utf-8"} "Method Not Allowed")
+ "allow"
+ (join ", " allowed))))
+
+(define
+ dream-router
+ (fn
+ (routes)
+ (let
+ ((flat (dr/flatten-routes routes)))
+ (fn (req) (dr/dispatch flat req (list))))))
diff --git a/lib/dream/run.sx b/lib/dream/run.sx
new file mode 100644
index 00000000..18401f86
--- /dev/null
+++ b/lib/dream/run.sx
@@ -0,0 +1,42 @@
+;; lib/dream/run.sx — Dream-on-SX entry point.
+;; dream-run installs a root handler into the existing SX HTTP server via
+;; (perform (:http/listen …)) — it does NOT implement its own socket loop. The
+;; host invokes the installed app per request with a raw request dict; the app
+;; adapts it to a dream-request, runs the handler, and serialises the response
+;; (status/headers/body/set-cookies, or a websocket upgrade). Depends on types.sx
+;; + websocket.sx. The listen transport is injectable for testing.
+
+;; ── response serialisation for the host ────────────────────────────
+(define
+ dr/serialize-response
+ (fn (resp) (if (dream-websocket? resp) {:websocket (dream-ws-handler resp) :body "" :headers (dream-headers resp) :status 101 :set-cookies (list)} {:body (dream-resp-body resp) :headers (dream-headers resp) :status (dream-status resp) :set-cookies (dream-resp-cookies resp)})))
+
+;; ── the app: raw host request -> serialised response ───────────────
+(define
+ dream-app
+ (fn
+ (handler)
+ (fn
+ (raw)
+ (let
+ ((req (dream-request (or (get raw :method) "GET") (or (get raw :target) (or (get raw :path) "/")) (or (get raw :headers) {}) (or (get raw :body) ""))))
+ (dr/serialize-response (dream-coerce-response (handler req)))))))
+
+;; ── dream-run ──────────────────────────────────────────────────────
+(define dream-default-port 8080)
+
+(define dream-run-with (fn (listen handler opts) (listen {:op "http/listen" :port (or (get opts :port) dream-default-port) :app (dream-app handler) :host (or (get opts :host) "0.0.0.0")})))
+
+(define dream-perform-listen (fn (op) (perform op)))
+
+(define
+ dream-run
+ (fn (handler) (dream-run-with dream-perform-listen handler {})))
+(define
+ dream-run-port
+ (fn
+ (handler port)
+ (dream-run-with dream-perform-listen handler {:port port})))
+(define
+ dream-run-opts
+ (fn (handler opts) (dream-run-with dream-perform-listen handler opts)))
diff --git a/lib/dream/session.sx b/lib/dream/session.sx
new file mode 100644
index 00000000..cb6c647a
--- /dev/null
+++ b/lib/dream/session.sx
@@ -0,0 +1,238 @@
+;; lib/dream/session.sx — Dream-on-SX cookie-backed sessions.
+;; The session cookie carries only a session id; fields live in a back-end store.
+;; The store is injectable: production wires it to (perform op); tests pass an
+;; in-memory store. Depends on types.sx. Also hosts shared cookie helpers reused
+;; by flash.sx and form.sx.
+
+;; ── cookie helpers (shared) ────────────────────────────────────────
+(define
+ dr/parse-cookies
+ (fn
+ (header)
+ (if
+ (or (nil? header) (= header ""))
+ {}
+ (reduce
+ (fn
+ (acc part)
+ (let
+ ((kv (trim part)))
+ (let
+ ((j (index-of kv "=")))
+ (if
+ (< j 0)
+ acc
+ (assoc
+ acc
+ (substr kv 0 j)
+ (substr kv (+ j 1)))))))
+ {}
+ (split header ";")))))
+
+(define
+ dream-cookie
+ (fn (req name) (get (dr/parse-cookies (dream-header req "cookie")) name)))
+(define
+ dream-cookies
+ (fn (req) (dr/parse-cookies (dream-header req "cookie"))))
+
+(define
+ dr/build-cookie
+ (fn
+ (name val opts)
+ (let
+ ((o (if (nil? opts) {} opts)))
+ (str
+ name
+ "="
+ val
+ "; Path="
+ (or (get o :path) "/")
+ (if (get o :http-only) "; HttpOnly" "")
+ (if (get o :secure) "; Secure" "")
+ (if (get o :same-site) (str "; SameSite=" (get o :same-site)) "")
+ (if (get o :max-age) (str "; Max-Age=" (get o :max-age)) "")))))
+
+(define
+ dream-set-cookie
+ (fn
+ (resp name val opts)
+ (assoc
+ resp
+ :set-cookies (concat
+ (or (get resp :set-cookies) (list))
+ (list (dr/build-cookie name val opts))))))
+
+(define
+ dream-resp-cookies
+ (fn (resp) (or (get resp :set-cookies) (list))))
+
+;; expire a cookie on the client
+(define
+ dream-drop-cookie
+ (fn (resp name) (dream-set-cookie resp name "" {:max-age 0})))
+
+;; ── signed cookie values (tamper-evident) ──────────────────────────
+;; NOTE: pure-SX keyed hash — not cryptographic; production should inject a host
+;; HMAC. Value carries no "." so the first "." splits value from signature.
+(define
+ dr/sess-hash
+ (fn (s) (dr/sess-hash-loop s 0 (string-length s) 7)))
+(define
+ dr/sess-hash-loop
+ (fn
+ (s i n h)
+ (if
+ (>= i n)
+ h
+ (dr/sess-hash-loop
+ s
+ (+ i 1)
+ n
+ (mod (+ (* h 131) (char-code (char-at s i))) 2147483647)))))
+(define
+ dr/sess-sig
+ (fn (secret val) (str (dr/sess-hash (str secret "|" val)))))
+
+(define
+ dream-cookie-sign
+ (fn (secret val) (str val "." (dr/sess-sig secret val))))
+(define
+ dream-cookie-unsign
+ (fn
+ (secret signed)
+ (if
+ (or (nil? signed) (= signed ""))
+ nil
+ (let
+ ((dot (index-of signed ".")))
+ (if
+ (< dot 0)
+ nil
+ (let
+ ((val (substr signed 0 dot))
+ (sig (substr signed (+ dot 1))))
+ (if (= sig (dr/sess-sig secret val)) val nil)))))))
+
+;; ── in-memory session store (tests + demos) ────────────────────────
+;; A backend is (fn (op) result) where op is a dict {:op ... :sid ... :key ...}.
+(define
+ dream-memory-sessions
+ (fn
+ ()
+ (let
+ ((store {}) (counter 0))
+ (fn
+ (op)
+ (let
+ ((kind (get op :op)))
+ (cond
+ ((= kind "session/create")
+ (begin
+ (set! counter (+ counter 1))
+ (let
+ ((sid (str "s" counter)))
+ (begin (set! store (assoc store sid {})) sid))))
+ ((= kind "session/exists") (has-key? store (get op :sid)))
+ ((= kind "session/get")
+ (get (or (get store (get op :sid)) {}) (get op :key)))
+ ((= kind "session/set")
+ (let
+ ((sid (get op :sid)))
+ (set!
+ store
+ (assoc
+ store
+ sid
+ (assoc
+ (or (get store sid) {})
+ (get op :key)
+ (get op :val))))))
+ ((= kind "session/load")
+ (or (get store (get op :sid)) {}))
+ ((= kind "session/clear")
+ (set! store (dissoc store (get op :sid))))
+ (else nil)))))))
+
+;; production back-end: every op suspends to the host
+(define dream-perform-sessions (fn (op) (perform op)))
+
+;; ── session middleware ─────────────────────────────────────────────
+(define dream-session-cookie-name "dream.session")
+
+(define
+ dream-sessions
+ (fn
+ (backend)
+ (fn
+ (next)
+ (fn
+ (req)
+ (let
+ ((sid0 (dream-cookie req dream-session-cookie-name)))
+ (let
+ ((have (and sid0 (backend {:op "session/exists" :sid sid0}))))
+ (let
+ ((sid (if have sid0 (backend {:op "session/create"}))))
+ (let
+ ((resp (next (assoc req :dream-session {:io backend :sid sid}))))
+ (if
+ have
+ resp
+ (dream-set-cookie
+ resp
+ dream-session-cookie-name
+ sid
+ {:path "/" :http-only true :same-site "Lax"}))))))))))
+
+;; signed variant: the cookie value is signed so a guessed/forged sid is rejected
+(define
+ dream-sessions-signed
+ (fn
+ (backend secret)
+ (fn
+ (next)
+ (fn
+ (req)
+ (let
+ ((sid0 (dream-cookie-unsign secret (dream-cookie req dream-session-cookie-name))))
+ (let
+ ((have (and sid0 (backend {:op "session/exists" :sid sid0}))))
+ (let
+ ((sid (if have sid0 (backend {:op "session/create"}))))
+ (let
+ ((resp (next (assoc req :dream-session {:io backend :sid sid}))))
+ (if
+ have
+ resp
+ (dream-set-cookie
+ resp
+ dream-session-cookie-name
+ (dream-cookie-sign secret sid)
+ {:path "/" :http-only true :same-site "Lax"}))))))))))
+
+;; ── handler-facing session API ─────────────────────────────────────
+(define dr/session-of (fn (req) (get req :dream-session)))
+(define dream-session-id (fn (req) (get (dr/session-of req) :sid)))
+
+(define
+ dream-session-field
+ (fn
+ (req key)
+ (let ((s (dr/session-of req))) ((get s :io) {:key key :op "session/get" :sid (get s :sid)}))))
+
+(define
+ dream-set-session-field
+ (fn
+ (req key val)
+ (let ((s (dr/session-of req))) (begin ((get s :io) {:val val :key key :op "session/set" :sid (get s :sid)}) req))))
+
+(define
+ dream-session-all
+ (fn (req) (let ((s (dr/session-of req))) ((get s :io) {:op "session/load" :sid (get s :sid)}))))
+
+(define
+ dream-invalidate-session
+ (fn
+ (req)
+ (let ((s (dr/session-of req))) (begin ((get s :io) {:op "session/clear" :sid (get s :sid)}) req))))
diff --git a/lib/dream/static.sx b/lib/dream/static.sx
new file mode 100644
index 00000000..372019a3
--- /dev/null
+++ b/lib/dream/static.sx
@@ -0,0 +1,182 @@
+;; lib/dream/static.sx — Dream-on-SX static file serving.
+;; dream-static mounts at a ** route and serves files under a root: content-type by
+;; extension, ETags + If-None-Match (304), and Range requests (206). The filesystem
+;; is injectable: production reads via (perform op); tests pass an in-memory map.
+;; Depends on types.sx.
+
+;; ── filesystem backends ────────────────────────────────────────────
+;; An fs is (fn (op) result); op {:op "file/read" :path p} -> content | nil.
+(define dream-static-perform-fs (fn (op) (perform op)))
+
+;; in-memory fs over a {path -> content} dict (tests + demos)
+(define
+ dream-memory-fs
+ (fn
+ (files)
+ (fn
+ (op)
+ (if (= (get op :op) "file/read") (get files (get op :path)) nil))))
+
+;; ── content-type by extension ──────────────────────────────────────
+(define dr/mime-types {:js "application/javascript" :jpeg "image/jpeg" :css "text/css; charset=utf-8" :ico "image/x-icon" :mjs "application/javascript" :html "text/html; charset=utf-8" :pdf "application/pdf" :jpg "image/jpeg" :json "application/json" :htm "text/html; charset=utf-8" :wasm "application/wasm" :webp "image/webp" :gif "image/gif" :png "image/png" :svg "image/svg+xml" :md "text/markdown; charset=utf-8" :xml "application/xml" :sx "text/plain; charset=utf-8" :txt "text/plain; charset=utf-8"})
+
+(define
+ dr/ext-of
+ (fn
+ (path)
+ (let
+ ((segs (split path ".")))
+ (if
+ (> (len segs) 1)
+ (lower (nth segs (- (len segs) 1)))
+ ""))))
+
+(define
+ dream-content-type-for
+ (fn
+ (path)
+ (or (get dr/mime-types (dr/ext-of path)) "application/octet-stream")))
+
+;; ── ETag (weak content hash) ───────────────────────────────────────
+(define
+ dr/static-hash
+ (fn (s) (dr/static-hash-loop s 0 (string-length s) 7)))
+(define
+ dr/static-hash-loop
+ (fn
+ (s i n h)
+ (if
+ (>= i n)
+ h
+ (dr/static-hash-loop
+ s
+ (+ i 1)
+ n
+ (mod (+ (* h 131) (char-code (char-at s i))) 2147483647)))))
+(define
+ dr/etag-of
+ (fn
+ (content)
+ (str "\"" (dr/static-hash content) "-" (string-length content) "\"")))
+(define
+ dr/etag-match?
+ (fn (inm etag) (and (not (nil? inm)) (or (= inm "*") (= inm etag)))))
+
+;; ── path safety ────────────────────────────────────────────────────
+(define
+ dr/static-relpath
+ (fn
+ (req)
+ (or (dream-param req "**") (substr (dream-path req) 1))))
+(define
+ dr/unsafe-path?
+ (fn (rel) (or (contains? rel "..") (starts-with? rel "/"))))
+(define
+ dr/path-join
+ (fn
+ (root rel)
+ (if (ends-with? root "/") (str root rel) (str root "/" rel))))
+
+;; ── range requests ─────────────────────────────────────────────────
+(define
+ dr/parse-range
+ (fn
+ (header total)
+ (let
+ ((eq (index-of header "=")))
+ (if
+ (< eq 0)
+ nil
+ (let
+ ((spec (substr header (+ eq 1))))
+ (let
+ ((dash (index-of spec "-")))
+ (if
+ (< dash 0)
+ nil
+ (let
+ ((s (substr spec 0 dash))
+ (e (substr spec (+ dash 1))))
+ (let
+ ((start (if (= s "") 0 (parse-int s)))
+ (end (if (= e "") (- total 1) (parse-int e))))
+ (if
+ (or
+ (< start 0)
+ (>= start total)
+ (> end (- total 1))
+ (> start end))
+ nil
+ {:start start :end end}))))))))))
+
+(define
+ dr/serve-range
+ (fn
+ (req content etag ctype)
+ (let
+ ((total (string-length content)))
+ (let
+ ((r (dr/parse-range (dream-header req "range") total)))
+ (if
+ (nil? r)
+ (dream-add-header
+ (dream-response 416 {:content-type ctype} "")
+ "content-range"
+ (str "bytes */" total))
+ (let
+ ((start (get r :start)) (end (get r :end)))
+ (dream-add-header
+ (dream-add-header
+ (dream-response
+ 206
+ {:content-type ctype}
+ (substr content start (+ 1 (- end start))))
+ "content-range"
+ (str "bytes " start "-" end "/" total))
+ "etag"
+ etag)))))))
+
+;; ── serving ────────────────────────────────────────────────────────
+(define
+ dr/serve-file
+ (fn
+ (req content)
+ (let
+ ((rel (dr/static-relpath req)))
+ (let
+ ((etag (dr/etag-of content)) (ctype (dream-content-type-for rel)))
+ (cond
+ ((dr/etag-match? (dream-header req "if-none-match") etag)
+ (dream-add-header (dream-empty 304) "etag" etag))
+ ((dream-header req "range")
+ (dr/serve-range req content etag ctype))
+ (else
+ (dream-add-header
+ (dream-add-header
+ (dream-response 200 {:content-type ctype} content)
+ "etag"
+ etag)
+ "accept-ranges"
+ "bytes")))))))
+
+(define
+ dream-static-with
+ (fn
+ (root fs)
+ (fn
+ (req)
+ (let
+ ((rel (dr/static-relpath req)))
+ (if
+ (dr/unsafe-path? rel)
+ (dream-html-status 403 "Forbidden")
+ (let
+ ((content (fs {:path (dr/path-join root rel) :op "file/read"})))
+ (if
+ (nil? content)
+ (dream-not-found)
+ (dr/serve-file req content))))))))
+
+(define
+ dream-static
+ (fn (root) (dream-static-with root dream-static-perform-fs)))
diff --git a/lib/dream/tests/api.sx b/lib/dream/tests/api.sx
new file mode 100644
index 00000000..add9b71f
--- /dev/null
+++ b/lib/dream/tests/api.sx
@@ -0,0 +1,77 @@
+;; lib/dream/tests/api.sx — facade: app builders + default stack.
+
+(define dream-ap-pass 0)
+(define dream-ap-fail 0)
+(define dream-ap-fails (list))
+
+(define
+ dream-ap-test
+ (fn
+ (name actual expected)
+ (if
+ (= actual expected)
+ (set! dream-ap-pass (+ dream-ap-pass 1))
+ (begin
+ (set! dream-ap-fail (+ dream-ap-fail 1))
+ (append! dream-ap-fails {:name name :actual actual :expected expected})))))
+
+(dream-ap-test "version is a string" (string? dream-version) true)
+
+;; ── dream-make-app: routes -> handler with default stack ───────────
+(define
+ dream-ap-routes
+ (list
+ (dream-get "/" (fn (req) (dream-html "hi
")))
+ (dream-get "/boom" (fn (req) (error "kaboom")))
+ (dream-get
+ "/raw"
+ (fn (req) (dream-response 200 {} "plain words")))))
+(define dream-ap-app (dream-make-app dream-ap-routes))
+
+(dream-ap-test
+ "app serves"
+ (dream-resp-body (dream-ap-app (dream-request "GET" "/" {} "")))
+ "hi
")
+(dream-ap-test
+ "app catches errors -> 500"
+ (dream-status (dream-ap-app (dream-request "GET" "/boom" {} "")))
+ 500)
+(dream-ap-test
+ "app 404 for unknown"
+ (dream-status (dream-ap-app (dream-request "GET" "/nope" {} "")))
+ 404)
+(dream-ap-test
+ "app sniffs content-type"
+ (dream-resp-header
+ (dream-ap-app (dream-request "GET" "/raw" {} ""))
+ "content-type")
+ "text/plain; charset=utf-8")
+
+;; ── dream-make-app-with: extra outer middleware ────────────────────
+(define
+ dream-ap-tag
+ (fn (next) (fn (req) (dream-add-header (next req) "X-App" "1"))))
+(define
+ dream-ap-app2
+ (dream-make-app-with (list dream-ap-tag) dream-ap-routes))
+(dream-ap-test
+ "extra middleware header"
+ (dream-resp-header
+ (dream-ap-app2 (dream-request "GET" "/" {} ""))
+ "x-app")
+ "1")
+
+;; ── dream-serve wires through dream-run ────────────────────────────
+(define dream-ap-captured nil)
+(define dream-ap-listen (fn (op) (begin (set! dream-ap-captured op) :ok)))
+(define
+ dream-ap-served
+ (dream-run-with dream-ap-listen (dream-make-app dream-ap-routes) {:port 7000}))
+(dream-ap-test "serve listens" dream-ap-served :ok)
+(dream-ap-test "serve port" (get dream-ap-captured :port) 7000)
+(dream-ap-test
+ "served app runs"
+ (get ((get dream-ap-captured :app) {:method "GET" :target "/"}) :body)
+ "hi
")
+
+(define dream-ap-tests-run! (fn () {:total (+ dream-ap-pass dream-ap-fail) :passed dream-ap-pass :failed dream-ap-fail :fails dream-ap-fails}))
diff --git a/lib/dream/tests/auth.sx b/lib/dream/tests/auth.sx
new file mode 100644
index 00000000..f5cb9806
--- /dev/null
+++ b/lib/dream/tests/auth.sx
@@ -0,0 +1,109 @@
+;; lib/dream/tests/auth.sx — base64, basic auth, bearer tokens.
+
+(define dream-au-pass 0)
+(define dream-au-fail 0)
+(define dream-au-fails (list))
+
+(define
+ dream-au-test
+ (fn
+ (name actual expected)
+ (if
+ (= actual expected)
+ (set! dream-au-pass (+ dream-au-pass 1))
+ (begin
+ (set! dream-au-fail (+ dream-au-fail 1))
+ (append! dream-au-fails {:name name :actual actual :expected expected})))))
+
+;; ── base64 ─────────────────────────────────────────────────────────
+(dream-au-test "encode Man" (dream-base64-encode "Man") "TWFu")
+(dream-au-test "encode Ma" (dream-base64-encode "Ma") "TWE=")
+(dream-au-test "encode M" (dream-base64-encode "M") "TQ==")
+(dream-au-test
+ "encode user:pass"
+ (dream-base64-encode "user:pass")
+ "dXNlcjpwYXNz")
+(dream-au-test "decode Man" (dream-base64-decode "TWFu") "Man")
+(dream-au-test "decode Ma" (dream-base64-decode "TWE=") "Ma")
+(dream-au-test "decode M" (dream-base64-decode "TQ==") "M")
+(dream-au-test
+ "decode user:pass"
+ (dream-base64-decode "dXNlcjpwYXNz")
+ "user:pass")
+(dream-au-test
+ "roundtrip phrase"
+ (dream-base64-decode (dream-base64-encode "Hello, World!"))
+ "Hello, World!")
+(dream-au-test
+ "roundtrip empty"
+ (dream-base64-decode (dream-base64-encode ""))
+ "")
+
+;; ── header parsing ─────────────────────────────────────────────────
+(dream-au-test
+ "bearer token"
+ (dream-bearer-token (dream-request "GET" "/" {:Authorization "Bearer abc.123"} ""))
+ "abc.123")
+(dream-au-test
+ "no bearer"
+ (dream-bearer-token (dream-request "GET" "/" {} ""))
+ nil)
+(dream-au-test
+ "basic creds"
+ (dream-basic-credentials (dream-request "GET" "/" {:Authorization "Basic dXNlcjpwYXNz"} ""))
+ {:pass "pass" :user "user"})
+(dream-au-test
+ "no basic"
+ (dream-basic-credentials (dream-request "GET" "/" {} ""))
+ nil)
+
+;; ── basic auth middleware ──────────────────────────────────────────
+(define dream-au-check (fn (u p) (and (= u "admin") (= p "secret"))))
+(define
+ dream-au-app
+ ((dream-basic-auth "Admin Area" dream-au-check)
+ (fn (req) (dream-text (str "hi " (dream-user req))))))
+
+(define dream-au-ok (dream-au-app (dream-request "GET" "/" {:Authorization (str "Basic " (dream-base64-encode "admin:secret"))} "")))
+(dream-au-test "basic ok reaches" (dream-resp-body dream-au-ok) "hi admin")
+(dream-au-test "basic ok status" (dream-status dream-au-ok) 200)
+
+(define dream-au-bad (dream-au-app (dream-request "GET" "/" {:Authorization (str "Basic " (dream-base64-encode "admin:wrong"))} "")))
+(dream-au-test "basic wrong 401" (dream-status dream-au-bad) 401)
+(dream-au-test
+ "basic wrong www-authenticate"
+ (contains? (dream-resp-header dream-au-bad "www-authenticate") "Admin Area")
+ true)
+(dream-au-test
+ "basic missing 401"
+ (dream-status (dream-au-app (dream-request "GET" "/" {} "")))
+ 401)
+
+;; ── bearer middleware ──────────────────────────────────────────────
+(define dream-au-tokens {:t-ada "ada" :t-bob "bob"})
+(define dream-au-lookup (fn (tok) (get dream-au-tokens tok)))
+(define
+ dream-au-bapp
+ ((dream-require-bearer dream-au-lookup)
+ (fn (req) (dream-text (dream-principal req)))))
+
+(dream-au-test
+ "bearer valid principal"
+ (dream-resp-body (dream-au-bapp (dream-request "GET" "/" {:Authorization "Bearer t-ada"} "")))
+ "ada")
+(dream-au-test
+ "bearer invalid 401"
+ (dream-status (dream-au-bapp (dream-request "GET" "/" {:Authorization "Bearer nope"} "")))
+ 401)
+(dream-au-test
+ "bearer missing 401"
+ (dream-status (dream-au-bapp (dream-request "GET" "/" {} "")))
+ 401)
+(dream-au-test
+ "bearer 401 header"
+ (dream-resp-header
+ (dream-au-bapp (dream-request "GET" "/" {} ""))
+ "www-authenticate")
+ "Bearer")
+
+(define dream-au-tests-run! (fn () {:total (+ dream-au-pass dream-au-fail) :passed dream-au-pass :failed dream-au-fail :fails dream-au-fails}))
diff --git a/lib/dream/tests/cors.sx b/lib/dream/tests/cors.sx
new file mode 100644
index 00000000..51ee9dc7
--- /dev/null
+++ b/lib/dream/tests/cors.sx
@@ -0,0 +1,93 @@
+;; lib/dream/tests/cors.sx — CORS decoration + preflight.
+
+(define dream-co-pass 0)
+(define dream-co-fail 0)
+(define dream-co-fails (list))
+
+(define
+ dream-co-test
+ (fn
+ (name actual expected)
+ (if
+ (= actual expected)
+ (set! dream-co-pass (+ dream-co-pass 1))
+ (begin
+ (set! dream-co-fail (+ dream-co-fail 1))
+ (append! dream-co-fails {:name name :actual actual :expected expected})))))
+
+(define dream-co-h (fn (req) (dream-text "payload")))
+(define dream-co-app (dream-cors dream-co-h))
+
+;; ── decoration of normal responses ─────────────────────────────────
+(define dream-co-get (dream-co-app (dream-request "GET" "/" {} "")))
+(dream-co-test
+ "allow-origin star"
+ (dream-resp-header dream-co-get "access-control-allow-origin")
+ "*")
+(dream-co-test "body preserved" (dream-resp-body dream-co-get) "payload")
+(dream-co-test "status preserved" (dream-status dream-co-get) 200)
+(dream-co-test
+ "no credentials by default"
+ (dream-resp-header dream-co-get "access-control-allow-credentials")
+ nil)
+
+;; ── preflight OPTIONS ──────────────────────────────────────────────
+(define
+ dream-co-pre
+ (dream-co-app (dream-request "OPTIONS" "/" {} "")))
+(dream-co-test "preflight 204" (dream-status dream-co-pre) 204)
+(dream-co-test
+ "preflight origin"
+ (dream-resp-header dream-co-pre "access-control-allow-origin")
+ "*")
+(dream-co-test
+ "preflight methods"
+ (contains?
+ (dream-resp-header dream-co-pre "access-control-allow-methods")
+ "POST")
+ true)
+(dream-co-test
+ "preflight headers"
+ (dream-resp-header dream-co-pre "access-control-allow-headers")
+ "Content-Type")
+(dream-co-test
+ "preflight max-age"
+ (dream-resp-header dream-co-pre "access-control-max-age")
+ "86400")
+
+;; ── custom origin ──────────────────────────────────────────────────
+(define
+ dream-co-custom
+ ((dream-cors-origin "https://app.example.com") dream-co-h))
+(dream-co-test
+ "custom origin"
+ (dream-resp-header
+ (dream-co-custom (dream-request "GET" "/" {} ""))
+ "access-control-allow-origin")
+ "https://app.example.com")
+
+;; ── credentials enabled ────────────────────────────────────────────
+(define
+ dream-co-cred
+ ((dream-cors-with (assoc dream-cors-defaults :credentials true))
+ dream-co-h))
+(dream-co-test
+ "credentials header"
+ (dream-resp-header
+ (dream-co-cred (dream-request "GET" "/" {} ""))
+ "access-control-allow-credentials")
+ "true")
+
+;; ── composes around a router ───────────────────────────────────────
+(define
+ dream-co-router
+ (dream-cors
+ (dream-router (list (dream-get "/api" (fn (req) (dream-json "{}")))))))
+(dream-co-test
+ "router cors origin"
+ (dream-resp-header
+ (dream-co-router (dream-request "GET" "/api" {} ""))
+ "access-control-allow-origin")
+ "*")
+
+(define dream-co-tests-run! (fn () {:total (+ dream-co-pass dream-co-fail) :passed dream-co-pass :failed dream-co-fail :fails dream-co-fails}))
diff --git a/lib/dream/tests/demos.sx b/lib/dream/tests/demos.sx
new file mode 100644
index 00000000..4e5c1d23
--- /dev/null
+++ b/lib/dream/tests/demos.sx
@@ -0,0 +1,198 @@
+;; lib/dream/tests/demos.sx — end-to-end demo apps exercising the full stack.
+
+(define dream-dm-pass 0)
+(define dream-dm-fail 0)
+(define dream-dm-fails (list))
+
+(define
+ dream-dm-test
+ (fn
+ (name actual expected)
+ (if
+ (= actual expected)
+ (set! dream-dm-pass (+ dream-dm-pass 1))
+ (begin
+ (set! dream-dm-fail (+ dream-dm-fail 1))
+ (append! dream-dm-fails {:name name :actual actual :expected expected})))))
+
+(define
+ dream-dm-req
+ (fn (method target headers) (dream-request method target headers "")))
+
+;; ── hello ──────────────────────────────────────────────────────────
+(dream-dm-test
+ "hello root"
+ (dream-resp-body (dream-hello-app (dream-dm-req "GET" "/" {})))
+ "Hello, World!
")
+(dream-dm-test
+ "hello name"
+ (dream-resp-body
+ (dream-hello-app (dream-dm-req "GET" "/hello/Ada" {})))
+ "Hello, Ada!
")
+(dream-dm-test
+ "hello content-type"
+ (dream-resp-header
+ (dream-hello-app (dream-dm-req "GET" "/" {}))
+ "content-type")
+ "text/html; charset=utf-8")
+
+;; ── counter (sessions) ─────────────────────────────────────────────
+(define dream-dm-cbackend (dream-memory-sessions))
+(define dream-dm-capp (dream-counter-app-with dream-dm-cbackend))
+
+(define dream-dm-c1 (dream-dm-capp (dream-dm-req "GET" "/" {})))
+(dream-dm-test
+ "counter first visit"
+ (dream-resp-body dream-dm-c1)
+ "You have visited this page 1 time(s).
")
+(dream-dm-test
+ "counter sets cookie"
+ (len (dream-resp-cookies dream-dm-c1))
+ 1)
+(dream-dm-test
+ "counter second visit"
+ (dream-resp-body (dream-dm-capp (dream-dm-req "GET" "/" {:Cookie "dream.session=s1"})))
+ "You have visited this page 2 time(s).
")
+(dream-dm-test
+ "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).
")
+(define
+ dream-dm-reset
+ (dream-dm-capp (dream-dm-req "POST" "/reset" {:Cookie "dream.session=s1"})))
+(dream-dm-test
+ "counter reset redirects"
+ (dream-status dream-dm-reset)
+ 303)
+(dream-dm-test
+ "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).
")
+(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/lib/dream/tests/error.sx b/lib/dream/tests/error.sx
new file mode 100644
index 00000000..27ad1e7c
--- /dev/null
+++ b/lib/dream/tests/error.sx
@@ -0,0 +1,90 @@
+;; lib/dream/tests/error.sx — status phrases + dream-catch.
+
+(define dream-er-pass 0)
+(define dream-er-fail 0)
+(define dream-er-fails (list))
+
+(define
+ dream-er-test
+ (fn
+ (name actual expected)
+ (if
+ (= actual expected)
+ (set! dream-er-pass (+ dream-er-pass 1))
+ (begin
+ (set! dream-er-fail (+ dream-er-fail 1))
+ (append! dream-er-fails {:name name :actual actual :expected expected})))))
+
+;; ── status phrases ─────────────────────────────────────────────────
+(dream-er-test "200 OK" (dream-status-text 200) "OK")
+(dream-er-test "404 Not Found" (dream-status-text 404) "Not Found")
+(dream-er-test
+ "405 phrase"
+ (dream-status-text 405)
+ "Method Not Allowed")
+(dream-er-test
+ "500 phrase"
+ (dream-status-text 500)
+ "Internal Server Error")
+(dream-er-test "unknown phrase" (dream-status-text 599) "Unknown")
+(dream-er-test "status line" (dream-status-line 404) "404 Not Found")
+(dream-er-test
+ "status page status"
+ (dream-status (dream-status-page 403))
+ 403)
+(dream-er-test
+ "status page body"
+ (dream-resp-body (dream-status-page 403))
+ "403 Forbidden
")
+
+;; ── dream-catch ────────────────────────────────────────────────────
+(define dream-er-boom (fn (req) (error "kaboom")))
+(define dream-er-ok (fn (req) (dream-text "fine")))
+
+(dream-er-test
+ "catch normal passes through"
+ (dream-resp-body
+ ((dream-catch dream-er-ok) (dream-request "GET" "/" {} "")))
+ "fine")
+(dream-er-test
+ "catch error -> 500"
+ (dream-status
+ ((dream-catch dream-er-boom) (dream-request "GET" "/" {} "")))
+ 500)
+(dream-er-test
+ "catch 500 body"
+ (dream-resp-body
+ ((dream-catch dream-er-boom) (dream-request "GET" "/" {} "")))
+ "500 Internal Server Error
")
+
+;; custom error page receives the error
+(define
+ dream-er-custom
+ (dream-catch-with (fn (req e) (dream-text (str "ERR:" e)))))
+(dream-er-test
+ "custom error page"
+ (dream-resp-body
+ ((dream-er-custom dream-er-boom) (dream-request "GET" "/" {} "")))
+ "ERR:kaboom")
+(dream-er-test
+ "custom passes normal through"
+ (dream-resp-body
+ ((dream-er-custom dream-er-ok) (dream-request "GET" "/" {} "")))
+ "fine")
+
+;; catch composes around a router
+(define
+ dream-er-app
+ (dream-catch
+ (dream-router
+ (list (dream-get "/boom" dream-er-boom) (dream-get "/ok" dream-er-ok)))))
+(dream-er-test
+ "router error caught"
+ (dream-status (dream-er-app (dream-request "GET" "/boom" {} "")))
+ 500)
+(dream-er-test
+ "router ok intact"
+ (dream-resp-body (dream-er-app (dream-request "GET" "/ok" {} "")))
+ "fine")
+
+(define dream-er-tests-run! (fn () {:total (+ dream-er-pass dream-er-fail) :passed dream-er-pass :failed dream-er-fail :fails dream-er-fails}))
diff --git a/lib/dream/tests/flash.sx b/lib/dream/tests/flash.sx
new file mode 100644
index 00000000..815f983b
--- /dev/null
+++ b/lib/dream/tests/flash.sx
@@ -0,0 +1,129 @@
+;; lib/dream/tests/flash.sx — codec + read-after-write across requests.
+
+(define dream-fl-pass 0)
+(define dream-fl-fail 0)
+(define dream-fl-fails (list))
+
+(define
+ dream-fl-test
+ (fn
+ (name actual expected)
+ (if
+ (= actual expected)
+ (set! dream-fl-pass (+ dream-fl-pass 1))
+ (begin
+ (set! dream-fl-fail (+ dream-fl-fail 1))
+ (append! dream-fl-fails {:name name :actual actual :expected expected})))))
+
+;; ── codec ──────────────────────────────────────────────────────────
+(dream-fl-test "encode one" (dr/flash-encode (list {:message "saved" :category "info"})) "info|saved")
+(dream-fl-test
+ "encode two"
+ (dr/flash-encode (list {:message "a" :category "info"} {:message "b" :category "error"}))
+ "info|a~error|b")
+(dream-fl-test "decode one" (dr/flash-decode "info|saved") (list {:message "saved" :category "info"}))
+(dream-fl-test "decode empty" (dr/flash-decode "") (list))
+(dream-fl-test
+ "roundtrip special chars"
+ (dr/flash-decode (dr/flash-encode (list {:message "a~b%c" :category "x|y"})))
+ (list {:message "a~b%c" :category "x|y"}))
+(dream-fl-test "escape pipe" (dr/flash-encode (list {:message "a|b" :category "c"})) "c|a%7Cb")
+
+;; extract a cookie value from a Set-Cookie string
+(define
+ dream-fl-cookie-val
+ (fn
+ (setc)
+ (let
+ ((after (substr setc (+ (index-of setc "=") 1))))
+ (substr after 0 (index-of after ";")))))
+
+;; ── read-after-write across requests ───────────────────────────────
+(define
+ dream-fl-set-h
+ (fn
+ (req)
+ (begin (dream-add-flash-message req "info" "Saved!") (dream-text "done"))))
+(define dream-fl-set-app (dream-flash dream-fl-set-h))
+
+;; request 1: add a flash, no incoming -> sets the flash cookie
+(define
+ dream-fl-r1
+ (dream-fl-set-app (dream-request "POST" "/save" {} "")))
+(dream-fl-test "writer body" (dream-resp-body dream-fl-r1) "done")
+(dream-fl-test
+ "writer sets flash cookie"
+ (len (dream-resp-cookies dream-fl-r1))
+ 1)
+(dream-fl-test
+ "writer has no incoming"
+ (dream-flash-messages
+ (assoc (dream-request "GET" "/" {} "") :dream-flash {:box (dr/flash-box) :incoming (list)}))
+ (list))
+
+;; request 2: carries the flash cookie -> handler reads it, cookie cleared
+(define
+ dream-fl-cval
+ (dream-fl-cookie-val (first (dream-resp-cookies dream-fl-r1))))
+(define
+ dream-fl-read-h
+ (fn
+ (req)
+ (let
+ ((msgs (dream-flash-messages req)))
+ (dream-text
+ (if (empty? msgs) "none" (dream-flash-message (first msgs)))))))
+(define dream-fl-read-app (dream-flash dream-fl-read-h))
+(define
+ dream-fl-r2
+ (dream-fl-read-app (dream-request "GET" "/" {:Cookie (str "dream.flash=" dream-fl-cval)} "")))
+(dream-fl-test "reader sees message" (dream-resp-body dream-fl-r2) "Saved!")
+(dream-fl-test
+ "reader clears cookie (Max-Age=0)"
+ (contains? (first (dream-resp-cookies dream-fl-r2)) "Max-Age=0")
+ true)
+
+;; request 3: no flash cookie -> nothing to read, no cookie set
+(define
+ dream-fl-r3
+ (dream-fl-read-app (dream-request "GET" "/" {} "")))
+(dream-fl-test "no flash -> none" (dream-resp-body dream-fl-r3) "none")
+(dream-fl-test
+ "no flash -> no cookie"
+ (len (dream-resp-cookies dream-fl-r3))
+ 0)
+
+;; ── multiple categories ────────────────────────────────────────────
+(define
+ dream-fl-multi-h
+ (fn
+ (req)
+ (begin
+ (dream-add-flash-message req "info" "i1")
+ (dream-add-flash-message req "error" "e1")
+ (dream-add-flash-message req "info" "i2")
+ (dream-text "ok"))))
+(define
+ dream-fl-multi-r1
+ ((dream-flash dream-fl-multi-h) (dream-request "GET" "/" {} "")))
+(define
+ dream-fl-multi-val
+ (dream-fl-cookie-val (first (dream-resp-cookies dream-fl-multi-r1))))
+(define
+ dream-fl-count-h
+ (fn
+ (req)
+ (dream-text
+ (str
+ (len (dream-flash-messages req))
+ "/"
+ (len (dream-flash-of req "info"))))))
+(define
+ dream-fl-multi-r2
+ ((dream-flash dream-fl-count-h) (dream-request "GET" "/" {:Cookie (str "dream.flash=" dream-fl-multi-val)} "")))
+(dream-fl-test
+ "multi: all + filtered counts"
+ (dream-resp-body dream-fl-multi-r2)
+ "3/2")
+
+(define dream-fl-tests-run! (fn () {:total (+ dream-fl-pass dream-fl-fail) :passed dream-fl-pass :failed dream-fl-fail :fails dream-fl-fails}))
diff --git a/lib/dream/tests/form.sx b/lib/dream/tests/form.sx
new file mode 100644
index 00000000..8b1e9eb4
--- /dev/null
+++ b/lib/dream/tests/form.sx
@@ -0,0 +1,226 @@
+;; lib/dream/tests/form.sx — urlencoded parsing, Ok/Err, CSRF accept/reject, multipart.
+
+(define dream-fo-pass 0)
+(define dream-fo-fail 0)
+(define dream-fo-fails (list))
+
+(define
+ dream-fo-test
+ (fn
+ (name actual expected)
+ (if
+ (= actual expected)
+ (set! dream-fo-pass (+ dream-fo-pass 1))
+ (begin
+ (set! dream-fo-fail (+ dream-fo-fail 1))
+ (append! dream-fo-fails {:name name :actual actual :expected expected})))))
+
+;; ── Result ─────────────────────────────────────────────────────────
+(dream-fo-test "ok? on ok" (dream-ok? (dream-ok 5)) true)
+(dream-fo-test "err? on ok" (dream-err? (dream-ok 5)) false)
+(dream-fo-test "ok value" (dream-ok-value (dream-ok {:a 1})) {:a 1})
+(dream-fo-test "err reason" (dream-err-reason (dream-err :bad)) "bad")
+
+;; ── urlencoded parsing ─────────────────────────────────────────────
+(define
+ dream-fo-req
+ (fn (body) (dream-request "POST" "/f" {:Content-Type "application/x-www-form-urlencoded"} body)))
+
+(dream-fo-test
+ "parse two fields"
+ (dream-form-fields (dream-fo-req "a=1&b=2"))
+ {:a "1" :b "2"})
+(dream-fo-test
+ "url-decoded value"
+ (dream-form-field (dream-fo-req "name=Ada+Lovelace") "name")
+ "Ada Lovelace")
+(dream-fo-test
+ "percent decode"
+ (dream-form-field (dream-fo-req "x=a%20b%21") "x")
+ "a b!")
+(dream-fo-test "empty body" (dream-form-fields (dream-fo-req "")) {})
+(dream-fo-test
+ "valueless key"
+ (dream-form-field (dream-fo-req "flag") "flag")
+ "")
+(dream-fo-test
+ "decoded key"
+ (dream-form-field (dream-fo-req "first%20name=x") "first name")
+ "x")
+
+;; ── CSRF sign + verify ─────────────────────────────────────────────
+(dream-fo-test
+ "sign deterministic"
+ (=
+ (dream-csrf-sign-default "secret" "s1")
+ (dream-csrf-sign-default "secret" "s1"))
+ true)
+(dream-fo-test
+ "sign secret-sensitive"
+ (=
+ (dream-csrf-sign-default "secret" "s1")
+ (dream-csrf-sign-default "other" "s1"))
+ false)
+(dream-fo-test
+ "sign session-sensitive"
+ (=
+ (dream-csrf-sign-default "secret" "s1")
+ (dream-csrf-sign-default "secret" "s2"))
+ false)
+(dream-fo-test
+ "token valid for own session"
+ (dr/csrf-valid?
+ dream-csrf-sign-default
+ "k"
+ "s1"
+ (dr/csrf-make-token dream-csrf-sign-default "k" "s1"))
+ true)
+(dream-fo-test
+ "token invalid for other session"
+ (dr/csrf-valid?
+ dream-csrf-sign-default
+ "k"
+ "s2"
+ (dr/csrf-make-token dream-csrf-sign-default "k" "s1"))
+ false)
+(dream-fo-test
+ "tampered token invalid"
+ (dr/csrf-valid? dream-csrf-sign-default "k" "s1" "s1.deadbeef")
+ false)
+(dream-fo-test
+ "empty token invalid"
+ (dr/csrf-valid? dream-csrf-sign-default "k" "s1" "")
+ false)
+(dream-fo-test
+ "nil token invalid"
+ (dr/csrf-valid? dream-csrf-sign-default "k" "s1" nil)
+ false)
+
+;; ── full stack: session -> csrf -> handler ─────────────────────────
+(define dream-fo-backend (dream-memory-sessions))
+(define dream-fo-sid (dream-fo-backend {:op "session/create"})) ;; s1
+
+(define
+ dream-fo-stack
+ (fn
+ (handler)
+ ((dream-sessions dream-fo-backend) ((dream-csrf "topsecret") handler))))
+
+(define
+ dream-fo-tag-out
+ (dream-resp-body
+ ((dream-fo-stack (fn (req) (dream-text (dream-csrf-tag req))))
+ (dream-request "GET" "/form" {:Cookie "dream.session=s1"} ""))))
+(dream-fo-test
+ "csrf-tag is hidden input"
+ (contains? dream-fo-tag-out "type=\"hidden\"")
+ true)
+(dream-fo-test
+ "csrf-tag names field"
+ (contains? dream-fo-tag-out "name=\"dream.csrf\"")
+ true)
+
+(define
+ dream-fo-good-token
+ (dr/csrf-make-token dream-csrf-sign-default "topsecret" "s1"))
+(define
+ dream-fo-submit
+ (fn
+ (token)
+ ((dream-fo-stack (fn (req) (let ((r (dream-form req))) (if (dream-ok? r) (dream-text (str "ok:" (get (dream-ok-value r) "msg"))) (dream-text (str "err:" (dream-err-reason r)))))))
+ (dream-request
+ "POST"
+ "/form"
+ {:Cookie "dream.session=s1"}
+ (str "msg=hello&dream.csrf=" token)))))
+
+(dream-fo-test
+ "valid csrf -> Ok fields"
+ (dream-resp-body (dream-fo-submit dream-fo-good-token))
+ "ok:hello")
+(dream-fo-test
+ "bad csrf -> Err"
+ (dream-resp-body (dream-fo-submit "s1.wrong"))
+ "err:csrf-token-invalid")
+(dream-fo-test
+ "missing csrf -> Err"
+ (dream-resp-body (dream-fo-submit ""))
+ "err:csrf-token-invalid")
+
+;; ── csrf-protect middleware auto-rejects ───────────────────────────
+(define
+ dream-fo-protected
+ (fn
+ (handler)
+ ((dream-sessions dream-fo-backend)
+ ((dream-csrf-protect "topsecret") handler))))
+(define dream-fo-ph (dream-fo-protected (fn (req) (dream-text "reached"))))
+
+(dream-fo-test
+ "GET passes without token"
+ (dream-resp-body (dream-fo-ph (dream-request "GET" "/x" {:Cookie "dream.session=s1"} "")))
+ "reached")
+(dream-fo-test
+ "POST without token 403"
+ (dream-status (dream-fo-ph (dream-request "POST" "/x" {:Cookie "dream.session=s1"} "")))
+ 403)
+(dream-fo-test
+ "POST with valid token reaches"
+ (dream-resp-body
+ (dream-fo-ph
+ (dream-request
+ "POST"
+ "/x"
+ {:Cookie "dream.session=s1"}
+ (str "dream.csrf=" dream-fo-good-token))))
+ "reached")
+
+;; ── multipart/form-data ────────────────────────────────────────────
+(define
+ dream-fo-mp-body
+ (str
+ "--B1\r\n"
+ "Content-Disposition: form-data; name=\"title\"\r\n\r\n"
+ "Hello\r\n"
+ "--B1\r\n"
+ "Content-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\r\nContent-Type: text/plain\r\n\r\n"
+ "line1\r\nline2\r\n"
+ "--B1--\r\n"))
+(define
+ dream-fo-mp-req
+ (dream-request "POST" "/upload" {:Content-Type "multipart/form-data; boundary=B1"} dream-fo-mp-body))
+(define dream-fo-mp (dream-multipart dream-fo-mp-req))
+(dream-fo-test "multipart is Ok" (dream-ok? dream-fo-mp) true)
+(define dream-fo-parts (dream-ok-value dream-fo-mp))
+(dream-fo-test "two parts" (len dream-fo-parts) 2)
+(dream-fo-test
+ "field value"
+ (dream-multipart-field dream-fo-parts "title")
+ "Hello")
+(dream-fo-test
+ "file part filename"
+ (get (dream-multipart-file dream-fo-parts "file") :filename)
+ "a.txt")
+(dream-fo-test
+ "file content-type"
+ (get (dream-multipart-file dream-fo-parts "file") :content-type)
+ "text/plain")
+(dream-fo-test
+ "file content keeps inner CRLF"
+ (get (dream-multipart-file dream-fo-parts "file") :content)
+ "line1\r\nline2")
+(dream-fo-test
+ "field is not a file"
+ (get (dream-multipart-file dream-fo-parts "title") :filename)
+ nil)
+(dream-fo-test
+ "non-multipart is Err"
+ (dream-err? (dream-multipart (dream-request "POST" "/x" {:Content-Type "text/plain"} "hi")))
+ true)
+(dream-fo-test
+ "quoted boundary parsed"
+ (dream-ok?
+ (dream-multipart (dream-request "POST" "/u" {:Content-Type "multipart/form-data; boundary=\"B1\""} dream-fo-mp-body)))
+ true)
+
+(define dream-fo-tests-run! (fn () {:total (+ dream-fo-pass dream-fo-fail) :passed dream-fo-pass :failed dream-fo-fail :fails dream-fo-fails}))
diff --git a/lib/dream/tests/headers.sx b/lib/dream/tests/headers.sx
new file mode 100644
index 00000000..cd81ad3f
--- /dev/null
+++ b/lib/dream/tests/headers.sx
@@ -0,0 +1,94 @@
+;; lib/dream/tests/headers.sx — security headers + cache-control.
+
+(define dream-hd-pass 0)
+(define dream-hd-fail 0)
+(define dream-hd-fails (list))
+
+(define
+ dream-hd-test
+ (fn
+ (name actual expected)
+ (if
+ (= actual expected)
+ (set! dream-hd-pass (+ dream-hd-pass 1))
+ (begin
+ (set! dream-hd-fail (+ dream-hd-fail 1))
+ (append! dream-hd-fails {:name name :actual actual :expected expected})))))
+
+(define dream-hd-h (fn (req) (dream-text "body")))
+(define dream-hd-req (dream-request "GET" "/" {} ""))
+
+;; ── security headers ───────────────────────────────────────────────
+(define dream-hd-sec ((dream-security-headers dream-hd-h) dream-hd-req))
+(dream-hd-test
+ "nosniff"
+ (dream-resp-header dream-hd-sec "x-content-type-options")
+ "nosniff")
+(dream-hd-test
+ "frame deny"
+ (dream-resp-header dream-hd-sec "x-frame-options")
+ "DENY")
+(dream-hd-test
+ "referrer policy"
+ (dream-resp-header dream-hd-sec "referrer-policy")
+ "no-referrer")
+(dream-hd-test
+ "no hsts by default"
+ (dream-resp-header dream-hd-sec "strict-transport-security")
+ nil)
+(dream-hd-test "body preserved" (dream-resp-body dream-hd-sec) "body")
+
+(define
+ dream-hd-hsts
+ ((dream-security-headers-with (assoc dream-security-defaults :hsts true))
+ dream-hd-h))
+(dream-hd-test
+ "hsts when enabled"
+ (contains?
+ (dream-resp-header
+ (dream-hd-hsts dream-hd-req)
+ "strict-transport-security")
+ "max-age=31536000")
+ true)
+
+;; ── cache-control ──────────────────────────────────────────────────
+(dream-hd-test
+ "cache public"
+ (dream-resp-header
+ (dream-cache (dream-text "x") 60)
+ "cache-control")
+ "public, max-age=60")
+(dream-hd-test
+ "private cache"
+ (dream-resp-header
+ (dream-private-cache (dream-text "x") 30)
+ "cache-control")
+ "private, max-age=30")
+(dream-hd-test
+ "no-store"
+ (dream-resp-header (dream-no-store (dream-text "x")) "cache-control")
+ "no-store")
+(dream-hd-test
+ "no-cache"
+ (dream-resp-header (dream-no-cache (dream-text "x")) "cache-control")
+ "no-cache, no-store, must-revalidate")
+
+;; ── cache middleware ───────────────────────────────────────────────
+(define dream-hd-capp ((dream-cache-for 300) dream-hd-h))
+(dream-hd-test
+ "cache-for stamps"
+ (dream-resp-header (dream-hd-capp dream-hd-req) "cache-control")
+ "public, max-age=300")
+
+;; ── composes around a router ───────────────────────────────────────
+(define
+ dream-hd-app
+ (dream-security-headers
+ (dream-router
+ (list (dream-get "/" (fn (req) (dream-html "hi
")))))))
+(dream-hd-test
+ "router security header"
+ (dream-resp-header (dream-hd-app dream-hd-req) "x-frame-options")
+ "DENY")
+
+(define dream-hd-tests-run! (fn () {:total (+ dream-hd-pass dream-hd-fail) :passed dream-hd-pass :failed dream-hd-fail :fails dream-hd-fails}))
diff --git a/lib/dream/tests/html.sx b/lib/dream/tests/html.sx
new file mode 100644
index 00000000..bf76b7bb
--- /dev/null
+++ b/lib/dream/tests/html.sx
@@ -0,0 +1,59 @@
+;; lib/dream/tests/html.sx — HTML escaping (+ demo XSS regression).
+
+(define dream-ht-pass 0)
+(define dream-ht-fail 0)
+(define dream-ht-fails (list))
+
+(define
+ dream-ht-test
+ (fn
+ (name actual expected)
+ (if
+ (= actual expected)
+ (set! dream-ht-pass (+ dream-ht-pass 1))
+ (begin
+ (set! dream-ht-fail (+ dream-ht-fail 1))
+ (append! dream-ht-fails {:name name :actual actual :expected expected})))))
+
+(dream-ht-test "escape ampersand" (dream-escape "a & b") "a & b")
+(dream-ht-test "escape lt gt" (dream-escape "") "<b>")
+(dream-ht-test "escape quote" (dream-escape "say \"hi\"") "say "hi"")
+(dream-ht-test "escape apostrophe" (dream-escape "it's") "it's")
+(dream-ht-test
+ "escape script tag"
+ (dream-escape "")
+ "<script>alert(1)</script>")
+(dream-ht-test
+ "ampersand first (no double-escape)"
+ (dream-escape "<")
+ "<")
+(dream-ht-test
+ "safe string unchanged"
+ (dream-escape "hello world")
+ "hello world")
+(dream-ht-test
+ "attr escapes value"
+ (dream-attr "title" "a\"b")
+ "title=\"a"b\"")
+(dream-ht-test
+ "escape-join"
+ (dream-escape-join " " (list "" ""))
+ "<a> <b>")
+
+;; ── todo demo escapes user input (XSS regression) ──────────────────
+(define dream-ht-store (dream-todo-store))
+((get dream-ht-store :add) "")
+(define
+ dream-ht-ctx
+ (assoc (dream-request "GET" "/" {} "") :dream-csrf {:sign dream-csrf-sign-default :sid "s1" :secret "k"}))
+(define dream-ht-rendered (dr/todo-render dream-ht-store dream-ht-ctx))
+(dream-ht-test
+ "todo escapes script"
+ (contains? dream-ht-rendered "<script>")
+ true)
+(dream-ht-test
+ "todo has no raw script"
+ (contains? dream-ht-rendered "