From 2b42aabe6b47a06cb50fc0b88ce6bcb604e42e03 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 7 Jun 2026 14:53:10 +0000 Subject: [PATCH] dream: dream-run entry point + request/response host adapter + 20 tests Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/dream/conformance.sh | 2 + lib/dream/run.sx | 42 +++++++++++++ lib/dream/tests/run.sx | 123 +++++++++++++++++++++++++++++++++++++++ plans/dream-on-sx.md | 11 +++- 4 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 lib/dream/run.sx create mode 100644 lib/dream/tests/run.sx diff --git a/lib/dream/conformance.sh b/lib/dream/conformance.sh index 26462fad..2748ccd3 100644 --- a/lib/dream/conformance.sh +++ b/lib/dream/conformance.sh @@ -30,6 +30,7 @@ MODULES=( "lib/dream/form.sx" "lib/dream/websocket.sx" "lib/dream/static.sx" + "lib/dream/run.sx" ) # Suites: NAME RUNNER-FN PATH @@ -42,6 +43,7 @@ SUITES=( "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" + "run dream-rn-tests-run! lib/dream/tests/run.sx" ) TMPFILE=$(mktemp); trap "rm -f $TMPFILE" EXIT diff --git a/lib/dream/run.sx b/lib/dream/run.sx new file mode 100644 index 00000000..18401f86 --- /dev/null +++ b/lib/dream/run.sx @@ -0,0 +1,42 @@ +;; lib/dream/run.sx — Dream-on-SX entry point. +;; dream-run installs a root handler into the existing SX HTTP server via +;; (perform (:http/listen …)) — it does NOT implement its own socket loop. The +;; host invokes the installed app per request with a raw request dict; the app +;; adapts it to a dream-request, runs the handler, and serialises the response +;; (status/headers/body/set-cookies, or a websocket upgrade). Depends on types.sx +;; + websocket.sx. The listen transport is injectable for testing. + +;; ── response serialisation for the host ──────────────────────────── +(define + dr/serialize-response + (fn (resp) (if (dream-websocket? resp) {:websocket (dream-ws-handler resp) :body "" :headers (dream-headers resp) :status 101 :set-cookies (list)} {:body (dream-resp-body resp) :headers (dream-headers resp) :status (dream-status resp) :set-cookies (dream-resp-cookies resp)}))) + +;; ── the app: raw host request -> serialised response ─────────────── +(define + dream-app + (fn + (handler) + (fn + (raw) + (let + ((req (dream-request (or (get raw :method) "GET") (or (get raw :target) (or (get raw :path) "/")) (or (get raw :headers) {}) (or (get raw :body) "")))) + (dr/serialize-response (dream-coerce-response (handler req))))))) + +;; ── dream-run ────────────────────────────────────────────────────── +(define dream-default-port 8080) + +(define dream-run-with (fn (listen handler opts) (listen {:op "http/listen" :port (or (get opts :port) dream-default-port) :app (dream-app handler) :host (or (get opts :host) "0.0.0.0")}))) + +(define dream-perform-listen (fn (op) (perform op))) + +(define + dream-run + (fn (handler) (dream-run-with dream-perform-listen handler {}))) +(define + dream-run-port + (fn + (handler port) + (dream-run-with dream-perform-listen handler {:port port}))) +(define + dream-run-opts + (fn (handler opts) (dream-run-with dream-perform-listen handler opts))) diff --git a/lib/dream/tests/run.sx b/lib/dream/tests/run.sx new file mode 100644 index 00000000..2298ea15 --- /dev/null +++ b/lib/dream/tests/run.sx @@ -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})) diff --git a/plans/dream-on-sx.md b/plans/dream-on-sx.md index bd7228f8..c97ee1d6 100644 --- a/plans/dream-on-sx.md +++ b/plans/dream-on-sx.md @@ -73,7 +73,7 @@ The five types: `request`, `response`, `handler = request -> response`, `middlew - `dream-websocket handler` — upgrades request; handler `(fn (ws) ...)`. - `dream-send ws msg`, `dream-receive ws`, `dream-close ws`. - [x] **Static files:** `dream-static root-path` — serves files, ETags, range requests. -- [ ] **`dream-run`**: wires root handler into SX's `perform (:http-listen ...)`. +- [x] **`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. @@ -189,6 +189,15 @@ Confirm scope before starting; some of these may be addable as Dream-internal he 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. ## Blockers