Compare commits

...

22 Commits

Author SHA1 Message Date
bd1e78c40f dream: security headers + cache-control middleware + 12 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 38s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:20:55 +00:00
0366373c8a dream: HTML escaping (dream-escape) + fix XSS hole in todo demo + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m9s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:18:49 +00:00
85aea61f3c dream: auth — pure-SX base64 + HTTP Basic + Bearer-token middleware + 23 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m11s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:16:29 +00:00
7fb833f54c dream: api.sx facade (make-app/serve) + README documenting public surface + 9 tests
Some checks are pending
Test, Build, and Deploy / test-build-deploy (push) Waiting to run
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:13:44 +00:00
6b9df03d01 dream: query/header convenience helpers + content negotiation + 18 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m1s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:11:55 +00:00
7d2d8478cc dream: signed session cookies (tamper-evident sid) + 11 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:10:03 +00:00
b061442c06 dream: pure-SX JSON encode + recursive-descent parse + 35 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 42s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:07:48 +00:00
30aece839b dream: CORS middleware + preflight handling + 12 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 52s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:04:43 +00:00
17ef5f50b3 dream: error-handling middleware (dream-catch) + status reason phrases + 15 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 48s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:03:17 +00:00
078872728e dream: router 405 Method Not Allowed + Allow header + automatic HEAD + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 51s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:00:29 +00:00
b1be3a36ec dream: chat (ws rooms) + todo (forms+CSRF) demos + 17 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m2s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:57:17 +00:00
2551109ffa dream: hello + counter demos + 10 end-to-end tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m6s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:54:46 +00:00
2b42aabe6b dream: dream-run entry point + request/response host adapter + 20 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 59s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:53:10 +00:00
04b44401fb dream: static file serving — mime, etags, 304, ranges, traversal guard + 28 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:51:25 +00:00
b67709dab5 dream: websockets — upgrade + send/receive/close/broadcast + 16 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 37s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:49:15 +00:00
fbc0c03f3a dream: multipart/form-data parsing + 9 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 53s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:47:10 +00:00
9a67ced748 dream: forms (urlencoded) + stateless signed CSRF + 26 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 59s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:43:41 +00:00
edff7735e7 dream: flash messages — single-request cookie store + 14 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m14s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:38:26 +00:00
55ec0b8f64 dream: cookie-backed sessions + in-memory store + 30 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m4s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:35:46 +00:00
b5a273cc99 dream: middleware pipeline + logger + content-type sniffer + 20 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m19s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:32:06 +00:00
66226b332b dream: router dispatch + path params + scopes + 27 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m9s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:29:50 +00:00
8fc7469a3c dream: core types — request/response/route records + 41 tests
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m3s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:27:05 +00:00
40 changed files with 4902 additions and 21 deletions

79
lib/dream/README.md Normal file
View File

@@ -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 "<h1>Hello, World!</h1>")))
(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).

33
lib/dream/api.sx Normal file
View File

@@ -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})))

172
lib/dream/auth.sx Normal file
View File

@@ -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)))

122
lib/dream/conformance.sh Normal file
View File

@@ -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

51
lib/dream/cors.sx Normal file
View File

@@ -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))))

46
lib/dream/demos/chat.sx Normal file
View File

@@ -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 "<h1>Rooms</h1>")))
(dream-get "/chat/:room" (dream-chat-route rooms))))))
;; entry point: (dream-run (dream-chat-app-with (dream-chat-rooms)))

View File

@@ -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 "<p>You have visited this page " n " time(s).</p>"))))))
;; 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)))

16
lib/dream/demos/hello.sx Normal file
View File

@@ -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 "<h1>Hello, World!</h1>")))
(dream-get
"/hello/:name"
(fn
(req)
(dream-html (str "<h1>Hello, " (dream-param req "name") "!</h1>")))))))
;; entry point (installs the handler on the host):
;; (dream-run dream-hello-app)

96
lib/dream/demos/todo.sx Normal file
View File

@@ -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
"<ul>"
(reduce
(fn
(acc it)
(str
acc
"<li>"
(if (get it :done) "[x] " "[ ] ")
(dream-escape (get it :text))
"</li>"))
""
((get store :all)))
"</ul>"
"<form method=\"post\" action=\"/add\">"
(dream-csrf-tag req)
"<input name=\"text\"><button>Add</button></form>")))
(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"))

41
lib/dream/error.sx Normal file
View File

@@ -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 "<h1>" (dream-status-line 500) "</h1>"))))
(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 "<h1>" (dream-status-line status) "</h1>"))))

91
lib/dream/flash.sx Normal file
View File

@@ -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))))

366
lib/dream/form.sx Normal file
View File

@@ -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
"<input type=\"hidden\" name=\""
dream-csrf-field-name
"\" value=\""
(dream-csrf-token req)
"\">")))
;; ── 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)))

54
lib/dream/headers.sx Normal file
View File

@@ -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)))))

24
lib/dream/html.sx Normal file
View File

@@ -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 "&" "&amp;") "<" "&lt;") ">" "&gt;")
"\""
"&quot;")
"'"
"&#39;")))
;; 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))))

183
lib/dream/json.sx Normal file
View File

@@ -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))))

92
lib/dream/middleware.sx Normal file
View File

@@ -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))))))

170
lib/dream/router.sx Normal file
View File

@@ -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))))))

42
lib/dream/run.sx Normal file
View File

@@ -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)))

238
lib/dream/session.sx Normal file
View File

@@ -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))))

182
lib/dream/static.sx Normal file
View File

@@ -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)))

77
lib/dream/tests/api.sx Normal file
View File

@@ -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 "<h1>hi</h1>")))
(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" "/" {} "")))
"<h1>hi</h1>")
(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)
"<h1>hi</h1>")
(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}))

109
lib/dream/tests/auth.sx Normal file
View File

@@ -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}))

93
lib/dream/tests/cors.sx Normal file
View File

@@ -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}))

198
lib/dream/tests/demos.sx Normal file
View File

@@ -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" "/" {})))
"<h1>Hello, World!</h1>")
(dream-dm-test
"hello name"
(dream-resp-body
(dream-hello-app (dream-dm-req "GET" "/hello/Ada" {})))
"<h1>Hello, Ada!</h1>")
(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)
"<p>You have visited this page 1 time(s).</p>")
(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"})))
"<p>You have visited this page 2 time(s).</p>")
(dream-dm-test
"counter third visit"
(dream-resp-body (dream-dm-capp (dream-dm-req "GET" "/" {:Cookie "dream.session=s1"})))
"<p>You have visited this page 3 time(s).</p>")
(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"})))
"<p>You have visited this page 1 time(s).</p>")
(dream-dm-test
"counter distinct session"
(dream-resp-body (dream-dm-capp (dream-dm-req "GET" "/" {})))
"<p>You have visited this page 1 time(s).</p>")
;; ── 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" "/" {})))
"<h1>Rooms</h1>")
;; ── 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}))

90
lib/dream/tests/error.sx Normal file
View File

@@ -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))
"<h1>403 Forbidden</h1>")
;; ── 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" "/" {} "")))
"<h1>500 Internal Server Error</h1>")
;; 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}))

129
lib/dream/tests/flash.sx Normal file
View File

@@ -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}))

226
lib/dream/tests/form.sx Normal file
View File

@@ -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}))

View File

@@ -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 "<p>hi</p>")))))))
(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}))

59
lib/dream/tests/html.sx Normal file
View File

@@ -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 &amp; b")
(dream-ht-test "escape lt gt" (dream-escape "<b>") "&lt;b&gt;")
(dream-ht-test "escape quote" (dream-escape "say \"hi\"") "say &quot;hi&quot;")
(dream-ht-test "escape apostrophe" (dream-escape "it's") "it&#39;s")
(dream-ht-test
"escape script tag"
(dream-escape "<script>alert(1)</script>")
"&lt;script&gt;alert(1)&lt;/script&gt;")
(dream-ht-test
"ampersand first (no double-escape)"
(dream-escape "&lt;")
"&amp;lt;")
(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&quot;b\"")
(dream-ht-test
"escape-join"
(dream-escape-join " " (list "<a>" "<b>"))
"&lt;a&gt; &lt;b&gt;")
;; ── todo demo escapes user input (XSS regression) ──────────────────
(define dream-ht-store (dream-todo-store))
((get dream-ht-store :add) "<script>alert(1)</script>")
(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 "&lt;script&gt;")
true)
(dream-ht-test
"todo has no raw script"
(contains? dream-ht-rendered "<script>")
false)
(define dream-ht-tests-run! (fn () {:total (+ dream-ht-pass dream-ht-fail) :passed dream-ht-pass :failed dream-ht-fail :fails dream-ht-fails}))

105
lib/dream/tests/json.sx Normal file
View File

@@ -0,0 +1,105 @@
;; lib/dream/tests/json.sx — JSON encode/parse round-trips.
(define dream-js-pass 0)
(define dream-js-fail 0)
(define dream-js-fails (list))
(define
dream-js-test
(fn
(name actual expected)
(if
(= actual expected)
(set! dream-js-pass (+ dream-js-pass 1))
(begin
(set! dream-js-fail (+ dream-js-fail 1))
(append! dream-js-fails {:name name :actual actual :expected expected})))))
;; ── encoding scalars ───────────────────────────────────────────────
(dream-js-test "encode int" (dream-json-encode 42) "42")
(dream-js-test "encode float" (dream-json-encode 1.5) "1.5")
(dream-js-test "encode true" (dream-json-encode true) "true")
(dream-js-test "encode false" (dream-json-encode false) "false")
(dream-js-test "encode nil" (dream-json-encode nil) "null")
(dream-js-test "encode string" (dream-json-encode "hi") "\"hi\"")
(dream-js-test
"encode string escapes quote"
(dream-json-encode "a\"b")
"\"a\\\"b\"")
(dream-js-test
"encode list"
(dream-json-encode (list 1 2 3))
"[1,2,3]")
(dream-js-test
"encode list of strings"
(dream-json-encode (list "a" "b"))
"[\"a\",\"b\"]")
(dream-js-test
"encode single-key dict"
(dream-json-encode {:a 1})
"{\"a\":1}")
(dream-js-test "encode empty list" (dream-json-encode (list)) "[]")
(dream-js-test "encode empty dict" (dream-json-encode {}) "{}")
;; ── parsing scalars ────────────────────────────────────────────────
(dream-js-test "parse int" (dream-json-parse "5") 5)
(dream-js-test "parse negative" (dream-json-parse "-7") -7)
(dream-js-test "parse float" (dream-json-parse "1.5") 1.5)
(dream-js-test "parse true" (dream-json-parse "true") true)
(dream-js-test "parse false" (dream-json-parse "false") false)
(dream-js-test "parse null" (dream-json-parse "null") nil)
(dream-js-test "parse string" (dream-json-parse "\"hello\"") "hello")
(dream-js-test "parse string escape" (dream-json-parse "\"a\\nb\"") "a\nb")
(dream-js-test
"parse array"
(dream-json-parse "[1,2,3]")
(list 1 2 3))
(dream-js-test "parse empty array" (dream-json-parse "[]") (list))
(dream-js-test
"parse with whitespace"
(dream-json-parse " [ 1 , 2 ] ")
(list 1 2))
;; ── parsing objects ────────────────────────────────────────────────
(define dream-js-obj (dream-json-parse "{\"x\":5,\"y\":\"hi\"}"))
(dream-js-test "parse obj number" (get dream-js-obj "x") 5)
(dream-js-test "parse obj string" (get dream-js-obj "y") "hi")
(dream-js-test "parse empty obj" (dream-json-parse "{}") {})
;; ── nested ─────────────────────────────────────────────────────────
(define dream-js-nested (dream-json-parse "{\"a\":[1,{\"b\":2}],\"c\":true}"))
(dream-js-test
"nested array first"
(first (get dream-js-nested "a"))
1)
(dream-js-test
"nested object in array"
(get (nth (get dream-js-nested "a") 1) "b")
2)
(dream-js-test "nested bool" (get dream-js-nested "c") true)
;; ── round-trips ────────────────────────────────────────────────────
(define dream-js-v {:name "Ada" :age 36 :tags (list "math" "engine")})
(define dream-js-rt (dream-json-parse (dream-json-encode dream-js-v)))
(dream-js-test "roundtrip name" (get dream-js-rt "name") "Ada")
(dream-js-test "roundtrip age" (get dream-js-rt "age") 36)
(dream-js-test
"roundtrip tags"
(get dream-js-rt "tags")
(list "math" "engine"))
;; ── response + request helpers ─────────────────────────────────────
(dream-js-test
"json-value content-type"
(dream-resp-header (dream-json-value {:ok true}) "content-type")
"application/json")
(dream-js-test
"json-value body"
(dream-resp-body (dream-json-value {:ok true}))
"{\"ok\":true}")
(dream-js-test
"json-body parses request"
(get (dream-json-body (dream-request "POST" "/" {} "{\"n\":9}")) "n")
9)
(define dream-js-tests-run! (fn () {:total (+ dream-js-pass dream-js-fail) :passed dream-js-pass :failed dream-js-fail :fails dream-js-fails}))

View File

@@ -0,0 +1,150 @@
;; lib/dream/tests/middleware.sx — composition, logger, content-type sniffer.
(define dream-mw-pass 0)
(define dream-mw-fail 0)
(define dream-mw-fails (list))
(define
dream-mw-test
(fn
(name actual expected)
(if
(= actual expected)
(set! dream-mw-pass (+ dream-mw-pass 1))
(begin
(set! dream-mw-fail (+ dream-mw-fail 1))
(append! dream-mw-fails {:name name :actual actual :expected expected})))))
(define dream-mw-req (dream-request "GET" "/p" {} ""))
;; ── pipeline composition order ─────────────────────────────────────
(define
dream-mw-wrap
(fn
(tag)
(fn
(next)
(fn
(req)
(dream-html (str tag "(" (dream-resp-body (next req)) ")"))))))
(define dream-mw-h (fn (req) (dream-html "h")))
(dream-mw-test
"pipeline empty is identity"
(dream-resp-body ((dream-pipeline (list) dream-mw-h) dream-mw-req))
"h")
(dream-mw-test
"pipeline single"
(dream-resp-body
((dream-pipeline (list (dream-mw-wrap "a")) dream-mw-h) dream-mw-req))
"a(h)")
(dream-mw-test
"pipeline first is outermost"
(dream-resp-body
((dream-pipeline (list (dream-mw-wrap "a") (dream-mw-wrap "b")) dream-mw-h)
dream-mw-req))
"a(b(h))")
(dream-mw-test
"no-middleware is identity"
(dream-resp-body ((dream-no-middleware dream-mw-h) dream-mw-req))
"h")
;; ── logger ─────────────────────────────────────────────────────────
(define dream-mw-clock-n 0)
(define
dream-mw-clock
(fn
()
(begin
(set! dream-mw-clock-n (+ dream-mw-clock-n 1))
dream-mw-clock-n)))
(define dream-mw-entries (list))
(define dream-mw-sink (fn (e) (append! dream-mw-entries e)))
(define
dream-mw-logged
((dream-logger-with dream-mw-clock dream-mw-sink)
(fn (req) (dream-html-status 201 "ok"))))
(define
dream-mw-lresp
(dream-mw-logged (dream-request "POST" "/log/path" {} "")))
(dream-mw-test
"logger passes response through"
(dream-resp-body dream-mw-lresp)
"ok")
(dream-mw-test "logger records one entry" (len dream-mw-entries) 1)
(dream-mw-test
"logger entry method"
(get (first dream-mw-entries) :method)
"POST")
(dream-mw-test
"logger entry path"
(get (first dream-mw-entries) :path)
"/log/path")
(dream-mw-test
"logger entry status"
(get (first dream-mw-entries) :status)
201)
(dream-mw-test
"logger entry elapsed"
(get (first dream-mw-entries) :elapsed)
1)
(dream-mw-test
"log-line format"
(dream-log-line {:path "/x" :status 200 :method "GET" :elapsed 4})
"GET /x -> 200 (4ms)")
;; ── content-type sniffer ───────────────────────────────────────────
(define dream-mw-ct (fn (handler) (dream-content-type handler)))
(define
dream-mw-sniff
(fn
(body)
(dream-resp-header
((dream-content-type (fn (req) (dream-response 200 {} body)))
dream-mw-req)
"content-type")))
(dream-mw-test
"sniff html"
(dream-mw-sniff "<p>hi</p>")
"text/html; charset=utf-8")
(dream-mw-test
"sniff doctype"
(dream-mw-sniff "<!doctype html>")
"text/html; charset=utf-8")
(dream-mw-test
"sniff json object"
(dream-mw-sniff "{\"a\":1}")
"application/json")
(dream-mw-test "sniff json array" (dream-mw-sniff "[1,2]") "application/json")
(dream-mw-test
"sniff plain text"
(dream-mw-sniff "just words")
"text/plain; charset=utf-8")
(dream-mw-test
"sniff empty body"
(dream-mw-sniff "")
"text/plain; charset=utf-8")
(dream-mw-test
"sniff does not override existing"
(dream-resp-header
((dream-content-type (fn (req) (dream-json "{}"))) dream-mw-req)
"content-type")
"application/json")
;; ── small middlewares ──────────────────────────────────────────────
(dream-mw-test
"set-header attaches"
(dream-resp-header
(((dream-set-header "X-A" "1") dream-mw-h) dream-mw-req)
"x-a")
"1")
(dream-mw-test
"tap-request rewrites"
(dream-resp-body
(((dream-tap-request (fn (req) (dream-set-body req "tapped"))) (fn (req) (dream-html (dream-body req))))
(dream-request "GET" "/" {} "orig")))
"tapped")
(define dream-mw-tests-run! (fn () {:total (+ dream-mw-pass dream-mw-fail) :passed dream-mw-pass :failed dream-mw-fail :fails dream-mw-fails}))

272
lib/dream/tests/router.sx Normal file
View File

@@ -0,0 +1,272 @@
;; lib/dream/tests/router.sx — routing dispatch, path params, scopes, 405/HEAD.
(define dream-rt-pass 0)
(define dream-rt-fail 0)
(define dream-rt-fails (list))
(define
dream-rt-test
(fn
(name actual expected)
(if
(= actual expected)
(set! dream-rt-pass (+ dream-rt-pass 1))
(begin
(set! dream-rt-fail (+ dream-rt-fail 1))
(append! dream-rt-fails {:name name :actual actual :expected expected})))))
(define
dream-rt-req
(fn (method target) (dream-request method target {} "")))
;; ── basic dispatch ─────────────────────────────────────────────────
(define
dream-rt-app
(dream-router
(list
(dream-get "/" (fn (req) (dream-text "home")))
(dream-get "/about" (fn (req) (dream-text "about")))
(dream-post "/submit" (fn (req) (dream-text "posted"))))))
(dream-rt-test
"GET / -> home"
(dream-resp-body (dream-rt-app (dream-rt-req "GET" "/")))
"home")
(dream-rt-test
"GET /about"
(dream-resp-body (dream-rt-app (dream-rt-req "GET" "/about")))
"about")
(dream-rt-test
"POST /submit"
(dream-resp-body (dream-rt-app (dream-rt-req "POST" "/submit")))
"posted")
(dream-rt-test
"unknown path 404"
(dream-status (dream-rt-app (dream-rt-req "GET" "/nope")))
404)
(dream-rt-test
"wrong method 405"
(dream-status (dream-rt-app (dream-rt-req "GET" "/submit")))
405)
(dream-rt-test
"trailing slash equiv"
(dream-resp-body (dream-rt-app (dream-rt-req "GET" "/about/")))
"about")
(dream-rt-test
"query ignored for routing"
(dream-resp-body (dream-rt-app (dream-rt-req "GET" "/about?x=1")))
"about")
;; ── path params ────────────────────────────────────────────────────
(define
dream-rt-papp
(dream-router
(list
(dream-get
"/users/:id"
(fn (req) (dream-text (dream-param req "id"))))
(dream-get
"/users/:id/posts/:pid"
(fn
(req)
(dream-text
(str (dream-param req "id") "-" (dream-param req "pid")))))
(dream-get
"/files/**"
(fn (req) (dream-text (dream-param req "**")))))))
(dream-rt-test
"single param"
(dream-resp-body (dream-rt-papp (dream-rt-req "GET" "/users/42")))
"42")
(dream-rt-test
"two params"
(dream-resp-body (dream-rt-papp (dream-rt-req "GET" "/users/7/posts/9")))
"7-9")
(dream-rt-test
"param no over-match"
(dream-status (dream-rt-papp (dream-rt-req "GET" "/users/7/extra")))
404)
(dream-rt-test
"catch-all captures rest"
(dream-resp-body (dream-rt-papp (dream-rt-req "GET" "/files/a/b/c.txt")))
"a/b/c.txt")
(dream-rt-test
"catch-all empty rest"
(dream-resp-body (dream-rt-papp (dream-rt-req "GET" "/files/")))
"")
;; ── route order: first match wins ──────────────────────────────────
(define
dream-rt-order
(dream-router
(list
(dream-get "/x/specific" (fn (req) (dream-text "specific")))
(dream-get "/x/:slug" (fn (req) (dream-text "generic"))))))
(dream-rt-test
"first match wins"
(dream-resp-body (dream-rt-order (dream-rt-req "GET" "/x/specific")))
"specific")
(dream-rt-test
"fallthrough to param"
(dream-resp-body (dream-rt-order (dream-rt-req "GET" "/x/other")))
"generic")
;; ── ANY method ─────────────────────────────────────────────────────
(define
dream-rt-any
(dream-router
(list (dream-any "/ping" (fn (req) (dream-text (dream-method req)))))))
(dream-rt-test
"ANY matches GET"
(dream-resp-body (dream-rt-any (dream-rt-req "GET" "/ping")))
"GET")
(dream-rt-test
"ANY matches DELETE"
(dream-resp-body (dream-rt-any (dream-rt-req "DELETE" "/ping")))
"DELETE")
;; ── handler returns bare string (coerced) ──────────────────────────
(define
dream-rt-coerce
(dream-router (list (dream-get "/s" (fn (req) "bare")))))
(dream-rt-test
"string coerced to 200"
(dream-status (dream-rt-coerce (dream-rt-req "GET" "/s")))
200)
(dream-rt-test
"string coerced body"
(dream-resp-body (dream-rt-coerce (dream-rt-req "GET" "/s")))
"bare")
;; ── scope: prefix mount ────────────────────────────────────────────
(define
dream-rt-scoped
(dream-router
(list
(dream-get "/" (fn (req) (dream-text "root")))
(dream-scope
"/api"
(list)
(list
(dream-get "/users" (fn (req) (dream-text "api-users")))
(dream-get
"/users/:id"
(fn
(req)
(dream-text (str "api-user-" (dream-param req "id"))))))))))
(dream-rt-test
"scope root still works"
(dream-resp-body (dream-rt-scoped (dream-rt-req "GET" "/")))
"root")
(dream-rt-test
"scope prefix path"
(dream-resp-body (dream-rt-scoped (dream-rt-req "GET" "/api/users")))
"api-users")
(dream-rt-test
"scope prefix param"
(dream-resp-body (dream-rt-scoped (dream-rt-req "GET" "/api/users/5")))
"api-user-5")
(dream-rt-test
"scope unprefixed 404"
(dream-status (dream-rt-scoped (dream-rt-req "GET" "/users")))
404)
;; ── scope: middleware applied to all routes ────────────────────────
(define
dream-rt-mw
(fn (next) (fn (req) (dream-add-header (next req) "X-Scope" "on"))))
(define
dream-rt-mwapp
(dream-router
(list
(dream-scope
"/v1"
(list dream-rt-mw)
(list (dream-get "/a" (fn (req) (dream-text "a"))))))))
(dream-rt-test
"scope mw header"
(dream-resp-header (dream-rt-mwapp (dream-rt-req "GET" "/v1/a")) "x-scope")
"on")
(dream-rt-test
"scope mw body intact"
(dream-resp-body (dream-rt-mwapp (dream-rt-req "GET" "/v1/a")))
"a")
;; ── nested scopes ──────────────────────────────────────────────────
(define
dream-rt-outer
(fn (next) (fn (req) (dream-add-header (next req) "X-Outer" "1"))))
(define
dream-rt-inner
(fn (next) (fn (req) (dream-add-header (next req) "X-Inner" "1"))))
(define
dream-rt-nested
(dream-router
(list
(dream-scope
"/api"
(list dream-rt-outer)
(list
(dream-scope
"/v2"
(list dream-rt-inner)
(list (dream-get "/thing" (fn (req) (dream-text "thing"))))))))))
(dream-rt-test
"nested path"
(dream-resp-body (dream-rt-nested (dream-rt-req "GET" "/api/v2/thing")))
"thing")
(dream-rt-test
"nested outer mw"
(dream-resp-header
(dream-rt-nested (dream-rt-req "GET" "/api/v2/thing"))
"x-outer")
"1")
(dream-rt-test
"nested inner mw"
(dream-resp-header
(dream-rt-nested (dream-rt-req "GET" "/api/v2/thing"))
"x-inner")
"1")
;; ── 405 Method Not Allowed + Allow ─────────────────────────────────
(define
dream-rt-mapp
(dream-router
(list
(dream-get "/r" (fn (req) (dream-text "get")))
(dream-post "/r" (fn (req) (dream-text "post")))
(dream-get "/only" (fn (req) (dream-html "<p>hi</p>"))))))
(define dream-rt-405 (dream-rt-mapp (dream-rt-req "DELETE" "/r")))
(dream-rt-test "405 status" (dream-status dream-rt-405) 405)
(dream-rt-test
"405 Allow has GET"
(contains? (dream-resp-header dream-rt-405 "allow") "GET")
true)
(dream-rt-test
"405 Allow has POST"
(contains? (dream-resp-header dream-rt-405 "allow") "POST")
true)
(dream-rt-test
"matching method still works"
(dream-resp-body (dream-rt-mapp (dream-rt-req "POST" "/r")))
"post")
(dream-rt-test
"no path is 404 not 405"
(dream-status (dream-rt-mapp (dream-rt-req "DELETE" "/absent")))
404)
;; ── automatic HEAD (serve GET, empty body) ─────────────────────────
(define dream-rt-head (dream-rt-mapp (dream-rt-req "HEAD" "/only")))
(dream-rt-test "HEAD status 200" (dream-status dream-rt-head) 200)
(dream-rt-test "HEAD empty body" (dream-resp-body dream-rt-head) "")
(dream-rt-test
"HEAD keeps content-type"
(dream-resp-header dream-rt-head "content-type")
"text/html; charset=utf-8")
(dream-rt-test
"HEAD on missing path 404"
(dream-status (dream-rt-mapp (dream-rt-req "HEAD" "/none")))
404)
(define dream-rt-tests-run! (fn () {:total (+ dream-rt-pass dream-rt-fail) :passed dream-rt-pass :failed dream-rt-fail :fails dream-rt-fails}))

123
lib/dream/tests/run.sx Normal file
View File

@@ -0,0 +1,123 @@
;; lib/dream/tests/run.sx — app adapter + dream-run wiring.
(define dream-rn-pass 0)
(define dream-rn-fail 0)
(define dream-rn-fails (list))
(define
dream-rn-test
(fn
(name actual expected)
(if
(= actual expected)
(set! dream-rn-pass (+ dream-rn-pass 1))
(begin
(set! dream-rn-fail (+ dream-rn-fail 1))
(append! dream-rn-fails {:name name :actual actual :expected expected})))))
;; ── app adapter: raw -> serialised response ────────────────────────
(define
dream-rn-router
(dream-router
(list
(dream-get "/" (fn (req) (dream-text "home")))
(dream-get
"/u/:id"
(fn (req) (dream-text (str "u=" (dream-param req "id")))))
(dream-post "/echo" (fn (req) (dream-text (dream-body req)))))))
(define dream-rn-app (dream-app dream-rn-router))
(define dream-rn-r1 (dream-rn-app {:method "GET" :target "/"}))
(dream-rn-test "serialised status" (get dream-rn-r1 :status) 200)
(dream-rn-test "serialised body" (get dream-rn-r1 :body) "home")
(dream-rn-test
"serialised content-type"
(get (get dream-rn-r1 :headers) "content-type")
"text/plain; charset=utf-8")
(dream-rn-test
"serialised set-cookies empty"
(get dream-rn-r1 :set-cookies)
(list))
(dream-rn-test
"adapts target+params"
(get (dream-rn-app {:method "GET" :target "/u/42"}) :body)
"u=42")
(dream-rn-test "adapts body" (get (dream-rn-app {:body "ping" :method "POST" :target "/echo"}) :body) "ping")
(dream-rn-test
"method defaults to GET"
(get (dream-rn-app {:target "/"}) :body)
"home")
(dream-rn-test
"missing target -> /"
(get (dream-rn-app {:method "GET"}) :status)
200)
(dream-rn-test
"unknown route 404"
(get (dream-rn-app {:method "GET" :target "/nope"}) :status)
404)
;; bare-string handler is coerced
(define dream-rn-bare (dream-app (fn (req) "plain")))
(dream-rn-test
"coerces bare string status"
(get (dream-rn-bare {:target "/"}) :status)
200)
(dream-rn-test
"coerces bare string body"
(get (dream-rn-bare {:target "/"}) :body)
"plain")
;; ── set-cookies flow through (session middleware) ──────────────────
(define
dream-rn-sess-app
(dream-app
((dream-sessions (dream-memory-sessions))
(fn (req) (dream-text "ok")))))
(define dream-rn-sess-r (dream-rn-sess-app {:method "GET" :target "/"}))
(dream-rn-test
"session set-cookie present"
(len (get dream-rn-sess-r :set-cookies))
1)
(dream-rn-test
"session cookie content"
(contains? (first (get dream-rn-sess-r :set-cookies)) "dream.session=")
true)
;; ── websocket upgrade serialisation ────────────────────────────────
(define
dream-rn-ws-app
(dream-app (dream-websocket (fn (ws) (dream-close ws)))))
(define dream-rn-ws-r (dream-rn-ws-app {:method "GET" :target "/ws"}))
(dream-rn-test "ws upgrade status 101" (get dream-rn-ws-r :status) 101)
(dream-rn-test
"ws handler carried"
(not (nil? (get dream-rn-ws-r :websocket)))
true)
;; ── dream-run wiring (mock listen captures the op) ─────────────────
(define dream-rn-captured nil)
(define
dream-rn-listen
(fn (op) (begin (set! dream-rn-captured op) :listening)))
(define
dream-rn-result
(dream-run-with dream-rn-listen dream-rn-router {:port 9000}))
(dream-rn-test "listen returns" dream-rn-result :listening)
(dream-rn-test "listen op kind" (get dream-rn-captured :op) "http/listen")
(dream-rn-test "listen port" (get dream-rn-captured :port) 9000)
(dream-rn-test
"default port"
(get
(begin
(dream-run-with dream-rn-listen dream-rn-router {})
dream-rn-captured)
:port)
8080)
;; the captured app is runnable
(dream-rn-test
"captured app serves"
(get ((get dream-rn-captured :app) {:method "GET" :target "/"}) :body)
"home")
(define dream-rn-tests-run! (fn () {:total (+ dream-rn-pass dream-rn-fail) :passed dream-rn-pass :failed dream-rn-fail :fails dream-rn-fails}))

197
lib/dream/tests/session.sx Normal file
View File

@@ -0,0 +1,197 @@
;; lib/dream/tests/session.sx — cookies, store, session round-trip, signed cookies.
(define dream-ss-pass 0)
(define dream-ss-fail 0)
(define dream-ss-fails (list))
(define
dream-ss-test
(fn
(name actual expected)
(if
(= actual expected)
(set! dream-ss-pass (+ dream-ss-pass 1))
(begin
(set! dream-ss-fail (+ dream-ss-fail 1))
(append! dream-ss-fails {:name name :actual actual :expected expected})))))
;; ── cookie parsing ─────────────────────────────────────────────────
(define dream-ss-creq (dream-request "GET" "/" {:Cookie "a=1; b=2; dream.session=s9"} ""))
(dream-ss-test "parse cookie a" (dream-cookie dream-ss-creq "a") "1")
(dream-ss-test "parse cookie b" (dream-cookie dream-ss-creq "b") "2")
(dream-ss-test
"parse session cookie"
(dream-cookie dream-ss-creq "dream.session")
"s9")
(dream-ss-test "missing cookie nil" (dream-cookie dream-ss-creq "z") nil)
(dream-ss-test
"no cookie header"
(dream-cookie (dream-request "GET" "/" {} "") "a")
nil)
;; ── cookie building ────────────────────────────────────────────────
(dream-ss-test
"build basic cookie"
(dr/build-cookie "k" "v" {})
"k=v; Path=/")
(dream-ss-test
"build httponly samesite"
(dr/build-cookie "sid" "x" {:http-only true :same-site "Lax"})
"sid=x; Path=/; HttpOnly; SameSite=Lax")
(dream-ss-test
"build max-age"
(dr/build-cookie "k" "v" {:max-age 0})
"k=v; Path=/; Max-Age=0")
(dream-ss-test
"set-cookie appends"
(len
(dream-resp-cookies
(dream-set-cookie (dream-html "x") "k" "v" {})))
1)
(dream-ss-test
"set-cookie two"
(len
(dream-resp-cookies
(dream-set-cookie
(dream-set-cookie (dream-html "x") "a" "1" {})
"b"
"2"
{})))
2)
(dream-ss-test
"drop cookie max-age 0"
(contains?
(first (dream-resp-cookies (dream-drop-cookie (dream-html "x") "k")))
"Max-Age=0")
true)
;; ── signed cookie values ───────────────────────────────────────────
(dream-ss-test
"sign/unsign roundtrip"
(dream-cookie-unsign "k" (dream-cookie-sign "k" "s5"))
"s5")
(dream-ss-test
"unsign wrong secret"
(dream-cookie-unsign "k2" (dream-cookie-sign "k" "s5"))
nil)
(dream-ss-test "unsign tampered" (dream-cookie-unsign "k" "s5.999") nil)
(dream-ss-test "unsign no dot" (dream-cookie-unsign "k" "s5") nil)
(dream-ss-test "unsign nil" (dream-cookie-unsign "k" nil) nil)
;; ── in-memory store ────────────────────────────────────────────────
(define dream-ss-store (dream-memory-sessions))
(define dream-ss-sid (dream-ss-store {:op "session/create"}))
(dream-ss-test "create returns id" dream-ss-sid "s1")
(dream-ss-test "new session exists" (dream-ss-store {:op "session/exists" :sid "s1"}) true)
(dream-ss-test "absent session not exists" (dream-ss-store {:op "session/exists" :sid "s99"}) false)
(dream-ss-test "get missing key nil" (dream-ss-store {:key "k" :op "session/get" :sid "s1"}) nil)
(dream-ss-store {:val "ada" :key "user" :op "session/set" :sid "s1"})
(dream-ss-test "set then get" (dream-ss-store {:key "user" :op "session/get" :sid "s1"}) "ada")
(dream-ss-store {:val "admin" :key "role" :op "session/set" :sid "s1"})
(dream-ss-test "load all fields" (dream-ss-store {:op "session/load" :sid "s1"}) {:role "admin" :user "ada"})
(dream-ss-test "second create distinct" (dream-ss-store {:op "session/create"}) "s2")
(dream-ss-store {:op "session/clear" :sid "s1"})
(dream-ss-test "clear removes" (dream-ss-store {:op "session/exists" :sid "s1"}) false)
;; ── middleware round-trip ──────────────────────────────────────────
(define dream-ss-backend (dream-memory-sessions))
(define
dream-ss-counter-h
(fn
(req)
(let
((n (or (dream-session-field req "count") 0)))
(begin
(dream-set-session-field req "count" (+ n 1))
(dream-text (str "count=" (+ n 1)))))))
(define dream-ss-app ((dream-sessions dream-ss-backend) dream-ss-counter-h))
(define dream-ss-r1 (dream-ss-app (dream-request "GET" "/" {} "")))
(dream-ss-test "first body count=1" (dream-resp-body dream-ss-r1) "count=1")
(dream-ss-test
"first sets one cookie"
(len (dream-resp-cookies dream-ss-r1))
1)
(dream-ss-test
"session cookie name+id"
(contains? (first (dream-resp-cookies dream-ss-r1)) "dream.session=s1")
true)
(dream-ss-test
"session cookie httponly"
(contains? (first (dream-resp-cookies dream-ss-r1)) "HttpOnly")
true)
(define dream-ss-r2 (dream-ss-app (dream-request "GET" "/" {:Cookie "dream.session=s1"} "")))
(dream-ss-test "second body count=2" (dream-resp-body dream-ss-r2) "count=2")
(dream-ss-test
"second sets no cookie"
(len (dream-resp-cookies dream-ss-r2))
0)
(define dream-ss-r3 (dream-ss-app (dream-request "GET" "/" {:Cookie "dream.session=s1"} "")))
(dream-ss-test "third body count=3" (dream-resp-body dream-ss-r3) "count=3")
(define dream-ss-r4 (dream-ss-app (dream-request "GET" "/" {:Cookie "dream.session=bogus"} "")))
(dream-ss-test
"bogus id starts fresh"
(dream-resp-body dream-ss-r4)
"count=1")
(dream-ss-test
"bogus id gets new cookie"
(len (dream-resp-cookies dream-ss-r4))
1)
;; ── session-all + invalidate via middleware ────────────────────────
(dream-ss-test
"session-all shows count"
(dream-session-all
(assoc (dream-request "GET" "/" {} "") :dream-session {:io dream-ss-backend :sid "s1"}))
{:count 3})
(define
dream-ss-invalidate-h
(fn (req) (begin (dream-invalidate-session req) (dream-text "bye"))))
(define
dream-ss-app3
((dream-sessions dream-ss-backend) dream-ss-invalidate-h))
(dream-ss-app3 (dream-request "GET" "/" {:Cookie "dream.session=s1"} ""))
(dream-ss-test "invalidate clears store" (dream-ss-backend {:op "session/exists" :sid "s1"}) false)
;; ── signed session middleware ──────────────────────────────────────
(define dream-ss-sbackend (dream-memory-sessions))
(define
dream-ss-sapp
((dream-sessions-signed dream-ss-sbackend "topsecret")
(fn (req) (dream-text (dream-session-id req)))))
(define dream-ss-sr1 (dream-ss-sapp (dream-request "GET" "/" {} "")))
(dream-ss-test "signed first sid" (dream-resp-body dream-ss-sr1) "s1")
(dream-ss-test
"signed cookie is signed"
(contains? (first (dream-resp-cookies dream-ss-sr1)) "dream.session=s1.")
true)
;; forged plaintext sid (no signature) is rejected -> a fresh session is made
(dream-ss-test
"forged plaintext rejected -> new session"
(dream-resp-body (dream-ss-sapp (dream-request "GET" "/" {:Cookie "dream.session=s1"} "")))
"s2")
;; a validly-signed cookie reuses the session
(define dream-ss-signed-val (dream-cookie-sign "topsecret" "s1"))
(define dream-ss-sr3 (dream-ss-sapp (dream-request "GET" "/" {:Cookie (str "dream.session=" dream-ss-signed-val)} "")))
(dream-ss-test "valid signed reuses s1" (dream-resp-body dream-ss-sr3) "s1")
(dream-ss-test
"valid signed sets no new cookie"
(len (dream-resp-cookies dream-ss-sr3))
0)
;; a cookie signed with the wrong secret is rejected
(dream-ss-test
"wrong-secret signed rejected"
(=
(dream-resp-body (dream-ss-sapp (dream-request "GET" "/" {:Cookie (str "dream.session=" (dream-cookie-sign "other" "s1"))} "")))
"s1")
false)
(define dream-ss-tests-run! (fn () {:total (+ dream-ss-pass dream-ss-fail) :passed dream-ss-pass :failed dream-ss-fail :fails dream-ss-fails}))

125
lib/dream/tests/static.sx Normal file
View File

@@ -0,0 +1,125 @@
;; lib/dream/tests/static.sx — content types, etags, 304, ranges, traversal.
(define dream-st-pass 0)
(define dream-st-fail 0)
(define dream-st-fails (list))
(define
dream-st-test
(fn
(name actual expected)
(if
(= actual expected)
(set! dream-st-pass (+ dream-st-pass 1))
(begin
(set! dream-st-fail (+ dream-st-fail 1))
(append! dream-st-fails {:name name :actual actual :expected expected})))))
;; ── content type + ext ─────────────────────────────────────────────
(dream-st-test "ext css" (dr/ext-of "a/b/style.css") "css")
(dream-st-test "ext multi-dot" (dr/ext-of "a.min.js") "js")
(dream-st-test "ext none" (dr/ext-of "README") "")
(dream-st-test
"ctype css"
(dream-content-type-for "x.css")
"text/css; charset=utf-8")
(dream-st-test
"ctype html"
(dream-content-type-for "x.html")
"text/html; charset=utf-8")
(dream-st-test "ctype png" (dream-content-type-for "x.png") "image/png")
(dream-st-test
"ctype unknown"
(dream-content-type-for "x.bin")
"application/octet-stream")
;; ── etag ───────────────────────────────────────────────────────────
(dream-st-test
"etag deterministic"
(= (dr/etag-of "abc") (dr/etag-of "abc"))
true)
(dream-st-test
"etag content-sensitive"
(= (dr/etag-of "abc") (dr/etag-of "abd"))
false)
(dream-st-test
"etag length-sensitive"
(= (dr/etag-of "ab") (dr/etag-of "abc"))
false)
;; ── serving via router mount ───────────────────────────────────────
(define dream-st-files {:/srv/app.css "body{color:red}" :/srv/index.html "<h1>Hi</h1>"})
(define dream-st-fs (dream-memory-fs dream-st-files))
(define
dream-st-app
(dream-router
(list (dream-get "/static/**" (dream-static-with "/srv" dream-st-fs)))))
(define
dream-st-get
(fn
(target headers)
(dream-st-app (dream-request "GET" target headers ""))))
(define dream-st-css (dream-st-get "/static/app.css" {}))
(dream-st-test "serve status 200" (dream-status dream-st-css) 200)
(dream-st-test "serve body" (dream-resp-body dream-st-css) "body{color:red}")
(dream-st-test
"serve content-type"
(dream-resp-header dream-st-css "content-type")
"text/css; charset=utf-8")
(dream-st-test
"serve accept-ranges"
(dream-resp-header dream-st-css "accept-ranges")
"bytes")
(dream-st-test
"serve has etag"
(not (nil? (dream-resp-header dream-st-css "etag")))
true)
(dream-st-test
"missing file 404"
(dream-status (dream-st-get "/static/nope.txt" {}))
404)
(dream-st-test
"traversal blocked 403"
(dream-status (dream-st-get "/static/../secret" {}))
403)
;; ── conditional: If-None-Match -> 304 ──────────────────────────────
(define dream-st-etag (dream-resp-header dream-st-css "etag"))
(define dream-st-304 (dream-st-get "/static/app.css" {:If-None-Match dream-st-etag}))
(dream-st-test "matching etag 304" (dream-status dream-st-304) 304)
(dream-st-test "304 empty body" (dream-resp-body dream-st-304) "")
(dream-st-test
"stale etag 200"
(dream-status (dream-st-get "/static/app.css" {:If-None-Match "\"stale\""}))
200)
(dream-st-test
"star etag 304"
(dream-status (dream-st-get "/static/app.css" {:If-None-Match "*"}))
304)
;; ── range requests ─────────────────────────────────────────────────
(define dream-st-range (dream-st-get "/static/app.css" {:Range "bytes=0-3"}))
(dream-st-test "range status 206" (dream-status dream-st-range) 206)
(dream-st-test "range body slice" (dream-resp-body dream-st-range) "body")
(dream-st-test
"range content-range"
(dream-resp-header dream-st-range "content-range")
"bytes 0-3/15")
(define dream-st-open (dream-st-get "/static/app.css" {:Range "bytes=5-"}))
(dream-st-test "open range body" (dream-resp-body dream-st-open) "color:red}")
(dream-st-test
"open range header"
(dream-resp-header dream-st-open "content-range")
"bytes 5-14/15")
(define dream-st-bad (dream-st-get "/static/app.css" {:Range "bytes=20-30"}))
(dream-st-test
"unsatisfiable range 416"
(dream-status dream-st-bad)
416)
(dream-st-test
"416 content-range"
(dream-resp-header dream-st-bad "content-range")
"bytes */15")
(define dream-st-tests-run! (fn () {:total (+ dream-st-pass dream-st-fail) :passed dream-st-pass :failed dream-st-fail :fails dream-st-fails}))

199
lib/dream/tests/types.sx Normal file
View File

@@ -0,0 +1,199 @@
;; lib/dream/tests/types.sx — request/response/route records + convenience.
(define dream-ty-pass 0)
(define dream-ty-fail 0)
(define dream-ty-fails (list))
(define
dream-ty-test
(fn
(name actual expected)
(if
(= actual expected)
(set! dream-ty-pass (+ dream-ty-pass 1))
(begin
(set! dream-ty-fail (+ dream-ty-fail 1))
(append! dream-ty-fails {:name name :actual actual :expected expected})))))
;; ── request construction + accessors ───────────────────────────────
(define
dream-ty-req
(dream-request "get" "/users/42?tab=info&x=1" {:X-Token "abc" :Content-Type "text/html"} "hello"))
(dream-ty-test "method uppercased" (dream-method dream-ty-req) "GET")
(dream-ty-test "path strips query" (dream-path dream-ty-req) "/users/42")
(dream-ty-test
"target keeps query"
(dream-target dream-ty-req)
"/users/42?tab=info&x=1")
(dream-ty-test "body" (dream-body dream-ty-req) "hello")
(dream-ty-test
"header case-insensitive"
(dream-header dream-ty-req "content-type")
"text/html")
(dream-ty-test
"header mixed case"
(dream-header dream-ty-req "X-Token")
"abc")
(dream-ty-test
"missing header is nil"
(dream-header dream-ty-req "absent")
nil)
(dream-ty-test
"query param tab"
(dream-query-param dream-ty-req "tab")
"info")
(dream-ty-test "query param x" (dream-query-param dream-ty-req "x") "1")
(dream-ty-test "params empty by default" (dream-param dream-ty-req "id") nil)
(dream-ty-test "is a request" (dream-request? dream-ty-req) true)
(dream-ty-test "string is not a request" (dream-request? "x") false)
;; ── query edge cases ───────────────────────────────────────────────
(dream-ty-test
"no query is empty"
(dream-query-param (dream-request "GET" "/plain" {} "") "k")
nil)
(dream-ty-test
"valueless query param"
(dream-query-param (dream-request "GET" "/p?flag" {} "") "flag")
"")
;; ── path params ────────────────────────────────────────────────────
(define dream-ty-req2 (dream-with-param dream-ty-req "id" "42"))
(dream-ty-test "with-param sets" (dream-param dream-ty-req2 "id") "42")
(dream-ty-test "with-param immutable" (dream-param dream-ty-req "id") nil)
(define dream-ty-req3 (dream-with-params dream-ty-req {:a "1" :b "2"}))
(dream-ty-test "with-params a" (dream-param dream-ty-req3 "a") "1")
(dream-ty-test "with-params b" (dream-param dream-ty-req3 "b") "2")
;; ── request convenience ────────────────────────────────────────────
(dream-ty-test "queries dict" (dream-queries dream-ty-req) {:x "1" :tab "info"})
(dream-ty-test
"query-or present"
(dream-query-param-or dream-ty-req "tab" "def")
"info")
(dream-ty-test
"query-or default"
(dream-query-param-or dream-ty-req "missing" "def")
"def")
(dream-ty-test "has-query yes" (dream-has-query? dream-ty-req "tab") true)
(dream-ty-test "has-query no" (dream-has-query? dream-ty-req "nope") false)
(dream-ty-test
"header-or present"
(dream-header-or dream-ty-req "x-token" "d")
"abc")
(dream-ty-test
"header-or default"
(dream-header-or dream-ty-req "x-absent" "d")
"d")
(dream-ty-test
"has-header yes"
(dream-has-header? dream-ty-req "Content-Type")
true)
(dream-ty-test
"has-header no"
(dream-has-header? dream-ty-req "x-absent")
false)
(dream-ty-test "param-or default" (dream-param-or dream-ty-req "id" "0") "0")
(dream-ty-test
"param-or present"
(dream-param-or dream-ty-req2 "id" "0")
"42")
(dream-ty-test
"content-type-of"
(dream-content-type-of dream-ty-req)
"text/html")
(dream-ty-test "method-is yes" (dream-method-is? dream-ty-req "get") true)
(dream-ty-test "method-is no" (dream-method-is? dream-ty-req "post") false)
(define dream-ty-jreq (dream-request "GET" "/" {:Accept "application/json, text/html"} ""))
(dream-ty-test
"accepts json"
(dream-accepts? dream-ty-jreq "application/json")
true)
(dream-ty-test
"accepts missing"
(dream-accepts? dream-ty-req "application/json")
false)
(dream-ty-test "wants-json yes" (dream-wants-json? dream-ty-jreq) true)
(dream-ty-test "wants-json no" (dream-wants-json? dream-ty-req) false)
;; ── response construction ──────────────────────────────────────────
(dream-ty-test "html status" (dream-status (dream-html "<p>")) 200)
(dream-ty-test "html body" (dream-resp-body (dream-html "<p>")) "<p>")
(dream-ty-test
"html content-type"
(dream-resp-header (dream-html "<p>") "content-type")
"text/html; charset=utf-8")
(dream-ty-test
"text content-type"
(dream-resp-header (dream-text "hi") "content-type")
"text/plain; charset=utf-8")
(dream-ty-test
"json content-type"
(dream-resp-header (dream-json "{}") "content-type")
"application/json")
(dream-ty-test
"html-status code"
(dream-status (dream-html-status 201 "ok"))
201)
(dream-ty-test
"not-found status"
(dream-status (dream-not-found))
404)
(dream-ty-test
"empty status"
(dream-status (dream-empty 204))
204)
(dream-ty-test "empty body" (dream-resp-body (dream-empty 204)) "")
(dream-ty-test
"redirect status"
(dream-status (dream-redirect "/home"))
303)
(dream-ty-test
"redirect location"
(dream-resp-header (dream-redirect "/home") "location")
"/home")
(dream-ty-test
"redirect-status code"
(dream-status (dream-redirect-status 301 "/x"))
301)
(dream-ty-test "is a response" (dream-response? (dream-html "x")) true)
;; ── response mutation ──────────────────────────────────────────────
(define dream-ty-resp (dream-add-header (dream-html "x") "X-Custom" "yes"))
(dream-ty-test
"add-header"
(dream-resp-header dream-ty-resp "x-custom")
"yes")
(dream-ty-test "add-header keeps body" (dream-resp-body dream-ty-resp) "x")
(dream-ty-test
"set-status"
(dream-status (dream-set-status (dream-html "x") 500))
500)
;; ── coercion ───────────────────────────────────────────────────────
(dream-ty-test
"coerce string"
(dream-status (dream-coerce-response "hi"))
200)
(dream-ty-test
"coerce string body"
(dream-resp-body (dream-coerce-response "hi"))
"hi")
(dream-ty-test
"coerce response passthrough"
(dream-status (dream-coerce-response (dream-empty 204)))
204)
;; ── route ──────────────────────────────────────────────────────────
(define dream-ty-h (fn (req) (dream-text "ok")))
(define dream-ty-route (dream-route "post" "/submit" dream-ty-h))
(dream-ty-test "route method" (dream-route-method dream-ty-route) "POST")
(dream-ty-test "route path" (dream-route-path dream-ty-route) "/submit")
(dream-ty-test "route is route" (dream-route? dream-ty-route) true)
(dream-ty-test
"route handler invokes"
(dream-resp-body ((dream-route-handler dream-ty-route) dream-ty-req))
"ok")
(define dream-ty-tests-run! (fn () {:total (+ dream-ty-pass dream-ty-fail) :passed dream-ty-pass :failed dream-ty-fail :fails dream-ty-fails}))

View File

@@ -0,0 +1,94 @@
;; lib/dream/tests/websocket.sx — upgrade, send/receive/close, broadcast.
(define dream-ws-pass 0)
(define dream-ws-fail 0)
(define dream-ws-fails (list))
(define
dream-ws-test
(fn
(name actual expected)
(if
(= actual expected)
(set! dream-ws-pass (+ dream-ws-pass 1))
(begin
(set! dream-ws-fail (+ dream-ws-fail 1))
(append! dream-ws-fails {:name name :actual actual :expected expected})))))
;; ── upgrade response ───────────────────────────────────────────────
(define dream-ws-echo (fn (ws) (dream-text "unused")))
(define
dream-ws-up
((dream-websocket dream-ws-echo) (dream-request "GET" "/ws" {} "")))
(dream-ws-test "upgrade status 101" (dream-status dream-ws-up) 101)
(dream-ws-test "is a websocket response" (dream-websocket? dream-ws-up) true)
(dream-ws-test
"plain response is not ws"
(dream-websocket? (dream-html "x"))
false)
(dream-ws-test
"upgrade header"
(dream-resp-header dream-ws-up "upgrade")
"websocket")
;; ── basic send / receive / close on a mock ─────────────────────────
(define dream-ws-w1 (dream-mock-ws (list "hi" "there")))
(dream-ws-test "open initially" (dream-ws-open? dream-ws-w1) true)
(dream-ws-test "receive first" (dream-receive dream-ws-w1) "hi")
(dream-ws-test "receive second" (dream-receive dream-ws-w1) "there")
(dream-ws-test "receive empty -> nil" (dream-receive dream-ws-w1) nil)
(dream-send dream-ws-w1 "out1")
(dream-send dream-ws-w1 "out2")
(dream-ws-test
"sent recorded"
(dream-ws-sent dream-ws-w1)
(list "out1" "out2"))
(dream-close dream-ws-w1)
(dream-ws-test "closed flag" (dream-ws-closed? dream-ws-w1) true)
(dream-ws-test "open? false after close" (dream-ws-open? dream-ws-w1) false)
;; ── echo handler driven over the upgrade response ──────────────────
(define
dream-ws-echo-h
(fn
(ws)
(let
((m (dream-receive ws)))
(if
(nil? m)
(dream-close ws)
(begin (dream-send ws (str "echo:" m)) (dream-ws-echo-h ws))))))
(define
dream-ws-echo-up
((dream-websocket dream-ws-echo-h)
(dream-request "GET" "/ws" {} "")))
(define dream-ws-echo-conn (dream-mock-ws (list "a" "b" "c")))
(dream-ws-run dream-ws-echo-up dream-ws-echo-conn)
(dream-ws-test
"echo all messages"
(dream-ws-sent dream-ws-echo-conn)
(list "echo:a" "echo:b" "echo:c"))
(dream-ws-test
"echo closes at end"
(dream-ws-closed? dream-ws-echo-conn)
true)
;; ── broadcast to a room ────────────────────────────────────────────
(define dream-ws-c1 (dream-mock-ws (list)))
(define dream-ws-c2 (dream-mock-ws (list)))
(define dream-ws-c3 (dream-mock-ws (list)))
(dream-ws-broadcast (list dream-ws-c1 dream-ws-c2 dream-ws-c3) "hello room")
(dream-ws-test
"broadcast c1"
(dream-ws-sent dream-ws-c1)
(list "hello room"))
(dream-ws-test
"broadcast c2"
(dream-ws-sent dream-ws-c2)
(list "hello room"))
(dream-ws-test
"broadcast c3"
(dream-ws-sent dream-ws-c3)
(list "hello room"))
(define dream-ws-tests-run! (fn () {:total (+ dream-ws-pass dream-ws-fail) :passed dream-ws-pass :failed dream-ws-fail :fails dream-ws-fails}))

175
lib/dream/types.sx Normal file
View File

@@ -0,0 +1,175 @@
;; lib/dream/types.sx — Dream-on-SX core types.
;; The five types: request, response, route. handler = request->response and
;; middleware = handler->handler are plain SX functions (no records needed).
;; request/response/route are dicts. Headers are dicts with lowercased string
;; keys; keywords are strings in SX, so :content-type == "content-type".
;; ── internal helpers ───────────────────────────────────────────────
(define
dr/normalize-headers
(fn
(h)
(reduce
(fn (acc k) (assoc acc (lower k) (get h k)))
{}
(keys h))))
(define
dr/path-of
(fn
(target)
(let
((i (index-of target "?")))
(if (< i 0) target (substr target 0 i)))))
(define
dr/query-of
(fn
(target)
(let
((i (index-of target "?")))
(if (< i 0) "" (substr target (+ i 1))))))
(define
dr/parse-pair
(fn
(acc pair)
(if
(= pair "")
acc
(let
((j (index-of pair "=")))
(if
(< j 0)
(assoc acc pair "")
(assoc
acc
(substr pair 0 j)
(substr pair (+ j 1))))))))
(define
dr/parse-query
(fn
(target)
(let
((q (dr/query-of target)))
(if
(= q "")
{}
(reduce dr/parse-pair {} (split q "&"))))))
;; ── request ────────────────────────────────────────────────────────
(define dream-request (fn (method target headers body) {:path (dr/path-of target) :params {} :query (dr/parse-query target) :body body :headers (dr/normalize-headers headers) :method (upper method) :target target}))
(define
dream-request?
(fn (x) (and (dict? x) (has-key? x :method) (has-key? x :path))))
(define dream-method (fn (req) (get req :method)))
(define dream-target (fn (req) (get req :target)))
(define dream-path (fn (req) (get req :path)))
(define dream-body (fn (req) (get req :body)))
(define
dream-header
(fn (req name) (get (get req :headers) (lower name))))
(define dream-query-param (fn (req name) (get (get req :query) name)))
(define dream-param (fn (req name) (get (get req :params) name)))
(define dream-params (fn (req) (get req :params)))
;; router fills path params during dispatch
(define
dream-with-param
(fn
(req name val)
(assoc req :params (assoc (get req :params) name val))))
(define
dream-with-params
(fn
(req more)
(assoc
req
:params (reduce
(fn (acc k) (assoc acc k (get more k)))
(get req :params)
(keys more)))))
(define dream-set-body (fn (req body) (assoc req :body body)))
;; ── request convenience ────────────────────────────────────────────
(define dream-queries (fn (req) (get req :query)))
(define
dream-query-param-or
(fn (req name default) (or (dream-query-param req name) default)))
(define dream-has-query? (fn (req name) (has-key? (get req :query) name)))
(define
dream-header-or
(fn (req name default) (or (dream-header req name) default)))
(define
dream-has-header?
(fn (req name) (has-key? (get req :headers) (lower name))))
(define
dream-param-or
(fn (req name default) (or (dream-param req name) default)))
(define dream-has-param? (fn (req name) (has-key? (get req :params) name)))
(define dream-content-type-of (fn (req) (dream-header req "content-type")))
(define dream-method-is? (fn (req m) (= (dream-method req) (upper m))))
(define
dream-accepts?
(fn
(req mime)
(let
((a (dream-header req "accept")))
(if a (contains? a mime) false))))
(define
dream-wants-json?
(fn (req) (dream-accepts? req "application/json")))
;; ── response ───────────────────────────────────────────────────────
(define dream-response (fn (status headers body) {:body body :headers (dr/normalize-headers headers) :status status}))
(define
dream-response?
(fn (x) (and (dict? x) (has-key? x :status) (has-key? x :body))))
(define dream-status (fn (resp) (get resp :status)))
(define
dream-resp-header
(fn (resp name) (get (get resp :headers) (lower name))))
(define dream-resp-body (fn (resp) (get resp :body)))
(define dream-headers (fn (resp) (get resp :headers)))
(define
dream-add-header
(fn
(resp name val)
(assoc resp :headers (assoc (get resp :headers) (lower name) val))))
(define dream-set-status (fn (resp status) (assoc resp :status status)))
;; smart constructors
(define dream-html (fn (body) (dream-response 200 {:content-type "text/html; charset=utf-8"} body)))
(define
dream-html-status
(fn (status body) (dream-response status {:content-type "text/html; charset=utf-8"} body)))
(define dream-text (fn (body) (dream-response 200 {:content-type "text/plain; charset=utf-8"} body)))
(define dream-json (fn (body) (dream-response 200 {:content-type "application/json"} body)))
(define dream-empty (fn (status) (dream-response status {} "")))
(define
dream-not-found
(fn () (dream-response 404 {:content-type "text/plain; charset=utf-8"} "Not Found")))
(define
dream-redirect
(fn (location) (dream-response 303 {:location location} "")))
(define
dream-redirect-status
(fn (status location) (dream-response status {:location location} "")))
;; coerce a handler result: strings become 200 text/html responses
(define
dream-coerce-response
(fn (x) (if (dream-response? x) x (dream-html x))))
;; ── route ──────────────────────────────────────────────────────────
(define dream-route (fn (method path handler) {:path path :handler handler :method (upper method)}))
(define
dream-route?
(fn (x) (and (dict? x) (has-key? x :handler) (has-key? x :path))))
(define dream-route-method (fn (r) (get r :method)))
(define dream-route-path (fn (r) (get r :path)))
(define dream-route-handler (fn (r) (get r :handler)))

42
lib/dream/websocket.sx Normal file
View File

@@ -0,0 +1,42 @@
;; lib/dream/websocket.sx — Dream-on-SX WebSockets.
;; dream-websocket wraps a (fn (ws) ...) handler into an ordinary handler that
;; returns a 101 upgrade response carrying the ws handler. The host detects the
;; upgrade, builds a ws backed by host IO, and runs the handler. The ws carries an
;; injectable io fn — a mock in-memory ws for tests, (perform op) in production.
;; Depends on types.sx.
;; ── upgrade response ───────────────────────────────────────────────
(define dream-websocket (fn (handler) (fn (req) {:websocket handler :body "" :headers {:connection "Upgrade" :upgrade "websocket"} :status 101})))
(define
dream-websocket?
(fn (resp) (and (dict? resp) (has-key? resp :websocket))))
(define dream-ws-handler (fn (resp) (get resp :websocket)))
;; ── ws operations (over an injectable io) ──────────────────────────
(define dream-send (fn (ws msg) ((get ws :io) {:op "ws/send" :msg msg})))
(define dream-receive (fn (ws) ((get ws :io) {:op "ws/receive"})))
(define dream-close (fn (ws) ((get ws :io) {:op "ws/close"})))
(define dream-ws-open? (fn (ws) ((get ws :io) {:op "ws/open?"})))
(define
dream-ws-broadcast
(fn (wss msg) (for-each (fn (ws) (dream-send ws msg)) wss)))
;; production io: every op suspends to the host
(define dream-ws-perform-io (fn (op) (perform op)))
(define dream-ws-from-io (fn (io) {:io io}))
;; ── in-memory mock ws (tests + demos) ──────────────────────────────
;; incoming is a list of messages dream-receive will yield in order.
(define
dream-mock-ws
(fn
(incoming)
(let ((inbox incoming) (outbox (list)) (closed false)) {:closed? (fn () closed) :outbox (fn () outbox) :io (fn (op) (cond ((= (get op :op) "ws/send") (begin (set! outbox (concat outbox (list (get op :msg)))) true)) ((= (get op :op) "ws/receive") (if (empty? inbox) nil (let ((m (first inbox))) (begin (set! inbox (rest inbox)) m)))) ((= (get op :op) "ws/close") (begin (set! closed true) true)) ((= (get op :op) "ws/open?") (not closed)) (else nil)))})))
;; test/demo introspection
(define dream-ws-sent (fn (ws) ((get ws :outbox))))
(define dream-ws-closed? (fn (ws) ((get ws :closed?))))
;; drive a ws handler (from an upgrade response) against a ws
(define dream-ws-run (fn (resp ws) ((dream-ws-handler resp) ws)))

View File

@@ -44,42 +44,127 @@ The user-facing story: rose-ash users who'd never touch s-expressions might writ
The five types: `request`, `response`, `handler = request -> response`, `middleware = handler -> handler`, `route`. Everything else is a function over these.
- [ ] **Core types** in `lib/dream/types.sx`: request/response records, route record.
- [ ] **Router** in `lib/dream/router.sx`:
- [x] **Core types** in `lib/dream/types.sx`: request/response records, route record.
- [x] **Router** in `lib/dream/router.sx`:
- `dream-get path handler`, `dream-post path handler`, etc. for all HTTP methods.
- `dream-scope prefix middlewares routes` — prefix mount with middleware chain.
- `dream-router routes` — dispatch tree, returns handler; no match → 404.
- Path param extraction: `:name` segments, `**` wildcard.
- `dream-param req name` — retrieve matched path param.
- [ ] **Middleware** in `lib/dream/middleware.sx`:
- [x] **Middleware** in `lib/dream/middleware.sx`:
- `dream-pipeline middlewares handler` — compose middleware left-to-right.
- `dream-no-middleware` — identity.
- Logger: `(dream-logger next req)` — logs method, path, status, timing.
- Content-type sniffer.
- [ ] **Sessions** in `lib/dream/session.sx`:
- [x] **Sessions** in `lib/dream/session.sx`:
- Cookie-backed session middleware.
- `dream-session-field req key`, `dream-set-session-field req key val`.
- `dream-invalidate-session req`.
- [ ] **Flash messages** in `lib/dream/flash.sx`:
- [x] **Flash messages** in `lib/dream/flash.sx`:
- `dream-flash-middleware` — single-request cookie store.
- `dream-add-flash-message req category msg`.
- `dream-flash-messages req` — returns list of `(category, msg)`.
- [ ] **Forms + CSRF** in `lib/dream/form.sx`:
- `dream-form req` — returns `(Ok fields)` or `(Err :csrf-token-invalid)`.
- `dream-multipart req` streaming multipart form data.
- CSRF middleware: stateless signed tokens, session-scoped.
- `dream-csrf-tag req` — returns hidden input fragment for SX templates.
- [ ] **WebSockets** in `lib/dream/websocket.sx`:
- [x] **Forms + CSRF** in `lib/dream/form.sx`:
- [x] `dream-form req` — returns `(Ok fields)` or `(Err :csrf-token-invalid)`.
- [x] `dream-multipart req` — multipart form data (in-memory, not yet streaming).
- [x] CSRF middleware: stateless signed tokens, session-scoped.
- [x] `dream-csrf-tag req` — returns hidden input fragment for SX templates.
- [x] **WebSockets** in `lib/dream/websocket.sx`:
- `dream-websocket handler` — upgrades request; handler `(fn (ws) ...)`.
- `dream-send ws msg`, `dream-receive ws`, `dream-close ws`.
- [ ] **Static files:** `dream-static root-path` — serves files, ETags, range requests.
- [ ] **`dream-run`**: wires root handler into SX's `perform (:http-listen ...)`.
- [ ] **Demos** in `lib/dream/demos/`:
- `hello.ml``lib/dream/demos/hello.sx`: "Hello, World!" route.
- `counter.ml``lib/dream/demos/counter.sx`: in-memory counter with sessions.
- `chat.ml``lib/dream/demos/chat.sx`: multi-room WebSocket chat.
- `todo.ml``lib/dream/demos/todo.sx`: CRUD list with forms + CSRF.
- [ ] Tests in `lib/dream/tests/`: routing dispatch, middleware composition, session round-trip, CSRF accept/reject, flash read-after-write — 60+ tests.
- [x] **Static files:** `dream-static root-path` — serves files, ETags, range requests.
- [x] **`dream-run`**: wires root handler into SX's `perform (:http-listen ...)`.
- [x] **Demos** in `lib/dream/demos/`:
- [x] `hello.ml``lib/dream/demos/hello.sx`: "Hello, World!" route.
- [x] `counter.ml``lib/dream/demos/counter.sx`: in-memory counter with sessions.
- [x] `chat.ml``lib/dream/demos/chat.sx`: multi-room WebSocket chat.
- [x] `todo.ml``lib/dream/demos/todo.sx`: CRUD list with forms + CSRF.
- [x] Tests in `lib/dream/tests/`: routing dispatch, middleware composition, session round-trip, CSRF accept/reject, flash read-after-write — **258 tests across 10 suites** (well past the 60+ target). Runner: `lib/dream/conformance.sh`.
**Roadmap complete (2026-06-07): all boxes ticked, 258/258 green.** Loop continues
with extensions + hardening below.
- **2026-06-07 — Ext: router HTTP correctness** (router suite 27→36, 267 total).
Dispatch now tracks which routes' *paths* matched: path matched + method didn't →
`405 Method Not Allowed` with an `Allow` header listing the path's methods (was a
blanket 404); genuinely-absent paths stay 404. `HEAD` falls back to the matching
`GET` handler with the body blanked but headers kept. `dr/route-params` (path-only
match) + `dr/method-accepts?` (ANY / HEAD→GET) + `dream-method-not-allowed`. NOTE:
in this worktree every `sx-tree` *edit* tool (`sx_replace_node`,
`sx_replace_by_pattern`, `sx_insert_near`) raises a yojson `Expected string, got
null` error — only `sx_write_file` works, so edits rewrite the whole file.
- **2026-06-07 — Ext: error handling + status phrases** (`lib/dream/error.sx`, 15
tests, 282 total). `dream-status-text` / `dream-status-line` reason-phrase map (string
keys); `dream-status-page` renders a status page. `dream-catch` is a `guard`-based
middleware that turns a raised error into a 500 (`dream-catch-with on-error` for a
custom page receiving `(req e)`); normal responses pass through untouched, composes
around a router. (`guard` catches explicit `(error …)` raises; `e` stringifies to the
message.)
- **2026-06-07 — Ext: CORS** (`lib/dream/cors.sx`, 12 tests, 294 total). `dream-cors`
decorates responses with `Access-Control-Allow-Origin` (+ credentials), and
short-circuits preflight `OPTIONS` with a 204 carrying Allow-Methods/Headers/Max-Age.
`dream-cors-origin` for a specific origin, `dream-cors-with opts` for full control
(origin/methods/headers/credentials/max-age). Composes around a router.
- **2026-06-07 — Ext: JSON** (`lib/dream/json.sx`, 35 tests, 329 total). Host JSON
primitives live in the ocaml-on-sx runtime (not the base env), so Dream ships its own
pure-SX `dream-json-encode` (scalars/list/dict, string escaping) + `dream-json-parse`
(recursive-descent over chars, objects/arrays/strings/numbers/true/false/null,
whitespace-tolerant). `dream-json-value` (encode → application/json response) and
`dream-json-body` (parse request body). GOTCHA: `number?` is unreliable in this env —
used `(= (type-of v) "number")`; `parse-float` handles decimals. Multi-key dict
encode order follows `keys` (non-deterministic) so tests assert via parse round-trip.
- **2026-06-07 — Ext: signed session cookies** (`lib/dream/session.sx`, session suite
30→41, 340 total). The default store uses guessable sids (`s1`, `s2`), so
`dream-sessions-signed backend secret` signs the cookie value (`sid.signature`) and
rejects any cookie whose signature doesn't verify — a forged plaintext `s1` or a
wrong-secret cookie yields a fresh session instead of a hijack. `dream-cookie-sign` /
`dream-cookie-unsign` (keyed hash; same not-cryptographic caveat — inject a host HMAC
in production). Plain `dream-sessions` unchanged for the no-secret case.
- **2026-06-07 — Ext: query/header convenience** (`lib/dream/types.sx`, types suite
41→59, 358 total). `dream-queries`, `dream-query-param-or` / `dream-header-or` /
`dream-param-or` (defaults), `dream-has-query?` / `-header?` / `-param?`,
`dream-content-type-of`, `dream-method-is?`, `dream-accepts?` / `dream-wants-json?`
(Accept-header content negotiation).
- **2026-06-07 — Ext: api.sx facade + README** (`lib/dream/api.sx`, 9 tests, 367 total).
`dream-version`, `dream-defaults` (pure stack: error-catch + content-type; logger is
opt-in since it performs IO), `dream-make-app routes`, `dream-make-app-with`,
`dream-serve`/`dream-serve-port`. `lib/dream/README.md` documents the full public
surface, quickstart, the dependency-injection testing story, and caveats. **All
planned extensions complete — 367/367 across 14 suites.**
- **2026-06-07 — Ext: auth** (`lib/dream/auth.sx`, 23 tests, 390 total). Pure-SX base64
codec (`dream-base64-encode`/`-decode`, arithmetic via `quotient`/`mod` — no bitwise),
verified against RFC vectors (Man/Ma/M padding). `dream-basic-auth realm check`
401 + `WWW-Authenticate: Basic realm=…`, attaches `:dream-user` on success;
`dream-basic-credentials` / `dream-authorization` accessors. `dream-require-bearer
check` → attaches `:dream-principal` or 401; `dream-bearer-token` accessor.
- **2026-06-07 — Ext: HTML escaping** (`lib/dream/html.sx`, 11 tests, 401 total).
`dream-escape` (&/</>/"/' entities, ampersand first to avoid double-escape),
`dream-attr`, `dream-escape-join`. Fixed a real **XSS hole** in the todo demo, which
interpolated user text into `<li>` unescaped — now `(dream-escape (get it :text))`;
regression test asserts `<script>` renders as `&lt;script&gt;`. 16 suites, 401/401.
- **2026-06-07 — Ext: security headers + cache-control** (`lib/dream/headers.sx`, 12
tests, 413 total). `dream-security-headers` middleware (X-Content-Type-Options
nosniff, X-Frame-Options DENY, Referrer-Policy no-referrer; opt-in HSTS via
`dream-security-headers-with`). Cache helpers `dream-cache`/`dream-private-cache`/
`dream-no-store`/`dream-no-cache` + `dream-cache-for` middleware. **dream-on-sx is
feature-complete: roadmap + 10 extensions, 413/413 across 17 suites. SATURATED —
remaining work is host-on-sx's job to consume `dream-run` (don't edit hosts/).**
## Extensions (post-roadmap)
The five-types core is complete; these harden it toward a production HTTP front door.
- [x] **Router HTTP correctness**: 405 Method Not Allowed + `Allow` header; automatic
HEAD (serve the GET handler with an empty body).
- [x] **Status reason phrases** + `dream-status-text` (`lib/dream/error.sx`).
- [x] **CORS middleware** (`dream-cors`).
- [x] **Error-handling middleware** (`dream-catch` / custom 500 templates; `guard`-based).
- [x] **Signed session cookies** (`dream-sessions-signed` — tamper-evident sid).
- [x] **JSON helpers** (encode + recursive-descent parse, pure SX).
- [x] **Query/header convenience** (`dream-queries`, `*-or` defaults, `dream-accepts?`).
- [x] **`api.sx` facade + README** — `dream-make-app` / `dream-serve` + `README.md`.
- [x] **Auth** — base64 (pure SX), HTTP Basic auth + Bearer-token middleware.
- [x] **HTML escaping** (`dream-escape`/`dream-attr`) — fixed an XSS hole in the todo demo.
- [x] **Security headers + cache-control** (`dream-security-headers`, `dream-cache`/`-no-store`).
## Stdlib additions Dream will need
@@ -104,8 +189,114 @@ Confirm scope before starting; some of these may be addable as Dream-internal he
## Progress log
_(awaiting activation conditions)_
- **2026-06-07 — Core types** (`lib/dream/types.sx`, 41 tests). OCaml gate verified
green (scoreboard 480/480, Phases 15 + Phase 6 stdlib). Dream is implemented in
plain SX over the CEK — keywords are strings, so headers are dicts with lowercased
string keys (`:content-type` == `"content-type"`). request (method/target/path/
query/headers/body/params), response (status/headers/body), route records with
constructors + accessors; smart response constructors (html/text/json/empty/
not-found/redirect); `dream-coerce-response` wraps bare strings; query-string
parsing. Conformance runner `lib/dream/conformance.sh` modelled on flow's.
- **2026-06-07 — Router** (`lib/dream/router.sx`, 27 tests). `dream-get/post/put/
delete/patch/head/options/any` route constructors; `dream-router` flattens routes
(incl. nested scopes) and dispatches by method+path, first-match-wins, 404 on no
match. Path matching is recursive over `/`-split segments: literal, `:name` binds
a param, `**` catch-all binds remaining path under key `"**"`. Trailing slashes and
query strings are ignored for routing. `dream-scope prefix mws routes` prepends the
prefix and folds the middleware chain (`m1 @@ m2 @@ h`, first = outermost) onto each
route's handler; nests correctly (inner mw innermost). Shared `dr/apply-middlewares`
fold will back `dream-pipeline`.
- **2026-06-07 — Middleware** (`lib/dream/middleware.sx`, 20 tests). `dream-pipeline`
(reuses `dr/apply-middlewares`), `dream-no-middleware` identity. `dream-logger-with
clock sink` is the testable core (records `{:method :path :status :elapsed}`);
`dream-logger` wires it to `(perform (:dream-clock))` / `(perform (:dream-log …))`;
`dream-log-line` formats one line. `dream-content-type` sniffs body (`<`→html,
`{`/`[`→json, else text) only when the handler left Content-Type unset. Bonus
`dream-set-header` and `dream-tap-request` combinators.
- **2026-06-07 — Sessions** (`lib/dream/session.sx`, 30 tests). Solved the
request→response mutation-visibility problem the way Dream does: the cookie carries
only a session id; fields live in an injectable back-end store (the mapping table's
`(perform (:session-get …))`). `dream-memory-sessions` is an in-memory store built
on a `set!`-mutated captured `let` binding (no `ref`/`atom` in base env);
`dream-perform-sessions` is the production back-end. `dream-sessions backend`
middleware reads/creates the id, attaches `{:sid :io}` to the request, and emits a
`Set-Cookie` (HttpOnly, SameSite=Lax) only for new sessions. Handler API:
`dream-session-field` / `dream-set-session-field` / `dream-session-all` /
`dream-invalidate-session` / `dream-session-id`. Also added shared cookie infra
(`dr/parse-cookies`, `dream-cookie(s)`, `dr/build-cookie`, `dream-set-cookie`,
`dream-resp-cookies`, `dream-drop-cookie`) — outgoing cookies accumulate in a
`:set-cookies` list on the response so multiple Set-Cookie headers don't collide;
reused by flash + CSRF. Full counter round-trip verified across three requests.
- **2026-06-07 — Flash** (`lib/dream/flash.sx`, 14 tests). `dream-flash` middleware:
decodes the incoming `dream.flash` cookie into the request, gives the handler a
mutable outbox cell (`dr/flash-box`, the same `set!`-captured-`let` trick), then on
response writes the outbox as a fresh flash cookie, or drops the cookie (Max-Age=0)
when there were incoming messages but no new ones — so messages show exactly once.
Handler API: `dream-add-flash-message` / `dream-flash-messages` (returns the
PREVIOUS request's messages) / `dream-flash-of` (by category) / accessors. Cookie
codec percent-escapes the `|`/`~`/`%` separators so categories/messages round-trip.
Read-after-write verified across request boundaries incl. multi-category.
- **2026-06-07 — Forms + CSRF (urlencoded)** (`lib/dream/form.sx`, 26 tests). Ok/Err
result values (`dream-ok`/`dream-err` + predicates/accessors). `dream-form-fields`
parses `application/x-www-form-urlencoded` with a full percent-decoder
(`%XX` via `char-from-code`, `+`→space). CSRF is stateless + signed + session-
scoped: token = `sid.signature`, verified by recomputing the signature and checking
the session id — no server storage. Signing is **injectable** (`dream-csrf-with`);
the default `dream-csrf-sign-default` is a pure-SX dual-base polynomial keyed hash
(NOT cryptographic — production should inject a host HMAC). `dream-csrf` attaches
context (needs the session middleware upstream for the sid); `dream-csrf-token` /
`dream-csrf-tag` (hidden input for templates); `dream-form` returns `Ok fields` or
`Err :csrf-token-invalid`; `dream-csrf-protect` auto-rejects unsafe methods (403)
lacking a valid token. Full session→csrf→form stack verified accept + reject.
Multipart deferred to the next commit.
- **2026-06-07 — Multipart** (`lib/dream/form.sx` +9 tests, 35 total). `dream-multipart
req` parses `multipart/form-data` into parts `{:name :filename :content-type
:content}`, returns `Ok parts | Err :not-multipart`. Needed a substring splitter
`dr/split-on` because the `split` primitive is **character-class** based (multi-char
separators split on every char) — important gotcha. Boundary from the Content-Type
(handles quoted form); segments filtered to those starting with CRLF; each split on
the first `\r\n\r\n` into headers/content with one edge CRLF stripped (inner CRLFs
in file content preserved). `dream-multipart-field` / `dream-multipart-file`
accessors. In-memory, not streaming (noted for future). `\r`/`\n` string escapes
work in SX literals.
- **2026-06-07 — WebSockets** (`lib/dream/websocket.sx`, 16 tests). `dream-websocket
handler` wraps a `(fn (ws) …)` into an ordinary handler returning a 101 upgrade
response carrying the ws handler (`dream-websocket?` / `dream-ws-handler` for the
host to detect + dispatch). `dream-send` / `dream-receive` / `dream-close` /
`dream-ws-open?` / `dream-ws-broadcast` operate over an injectable io; production io
is `(perform op)`, tests use `dream-mock-ws` (in-memory inbox/outbox/closed via the
cell pattern) with `dream-ws-sent` / `dream-ws-closed?` introspection and
`dream-ws-run` to drive a handler. Echo loop + room broadcast verified.
- **2026-06-07 — Static files** (`lib/dream/static.sx`, 28 tests). `dream-static root`
mounts at a `**` route and serves files: content-type by extension (mime map),
weak ETag (`"hash-length"`) with `If-None-Match` → 304 (incl. `*`), and `Range:
bytes=` requests → 206 with `Content-Range` (open-ended `bytes=N-` supported,
unsatisfiable → 416). `..`/absolute path traversal → 403; missing → 404; full
responses advertise `Accept-Ranges`. Filesystem is injectable —
`dream-static-perform-fs` (host) vs `dream-memory-fs` (in-memory map for tests).
- **2026-06-07 — dream-run** (`lib/dream/run.sx`, 20 tests). `dream-run handler`
installs the root handler via `(perform (:http/listen {:port :host :app …}))` — no
socket code, it wraps the existing server. `dream-app handler` is the adapter the
host invokes per request: raw `{:method :target :headers :body}` → `dream-request`
→ handler → serialised `{:status :headers :body :set-cookies}`, or a `{:status 101
:websocket …}` upgrade. Bare-string handlers coerced; method defaults to GET;
set-cookies (from session/flash) flow through. Listen transport injectable
(`dream-run-with`) so the full wiring is tested with a mock that captures the op and
re-runs the captured app. `dream-run-port` / `dream-run-opts` variants.
- **2026-06-07 — Demos: hello + counter** (`lib/dream/demos/`, 10 tests). `hello.sx`
is the canonical router with a `:name` param route. `counter.sx` is a per-session
visit counter on the session middleware (+ a `/reset` POST that redirects),
demonstrating session isolation across browsers. End-to-end tests drive both apps
as the host would. chat (ws) + todo (forms+CSRF) next.
- **2026-06-07 — Demos: chat + todo** (`lib/dream/demos/`, demos suite now 27 tests).
`chat.sx` is a multi-room WebSocket chat over a room registry (join/leave/members/
broadcast on the cell pattern); verified three clients see each other's broadcasts
and a disconnect leaves the room. `todo.sx` is a CRUD list wiring session→csrf→
router: add/toggle/delete go through `dream-form` (CSRF-guarded), an in-memory store
holds items, pages render the list + `dream-csrf-tag`; verified the full
add→render→toggle→delete cycle plus a 403 on a token-less POST. ws object equality
is by reference, so the `:leave` filter removes exactly the right connection.
## Blockers
_(none yet — plan is cold)_
_(none — gate green, loop active)_