Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
Dream-on-SX: OCaml's Dream web framework on the SX CEK
[activated — target user confirmed; gated only on ocaml-on-sx]
Carved out of plans/ocaml-on-sx.md. The OCaml-on-SX plan was scoped down to substrate validation + HM + reference oracle (Phases 1–5 + minimal stdlib slice). Dream is the practical alternative-stack story — the opposite framing — and is now the chosen framework layer for the rose-ash host: the decision is to move off Quart and adopt Dream (not Quart) as the ergonomic HTTP front door over the native SX server. plans/host-on-sx.md Phase 4 is the concrete consumer that pulls Dream.
Target user — CONFIRMED. The earlier "needs a concrete target user" condition is met: rose-ash itself is the user — the subsystems (feed/acl/mod/commerce/identity/…) need an ergonomic HTTP front door, and the project owner has chosen Dream over Quart for it. This plan is no longer cold; it is gated, not deferred.
Do not start without (the one remaining gate):
- OCaml-on-SX Phases 1–5 + Phase 6 minimal stdlib green. (As of writing ocaml-on-sx is at 480/480 and advancing — verify its scoreboard covers Phases 1–5 + the stdlib slice before starting.)
Until that gate is green, the native server (host-on-sx Phases 1–3) carries the host; do not block host migration on Dream.
Why this might be worth doing (when the time comes)
Dream is the cleanest middleware-shaped HTTP framework in any language:
handler = request -> response promisemiddleware = handler -> handlerm1 @@ m2 @@ handler— left-fold composition
It maps onto SX with almost no impedance — @@ is function composition, request → response promise is (perform (:http-respond ...)), middleware chain is plain SX function composition. So the integration cost is low if the OCaml-on-SX foundation is in place.
The user-facing story: rose-ash users who'd never touch s-expressions might write Dream/OCaml apps that integrate with the same federation, auth, and storage primitives. Demo: a Dream app serving sx.rose-ash.com — the framework that describes the runtime it runs on.
Dream semantic mappings
| Dream construct | SX mapping |
|---|---|
handler = request -> response promise |
(fn (req) (perform (:http-respond ...))) |
middleware = handler -> handler |
(fn (next) (fn (req) ...)) |
Dream.router [routes] |
(ocaml-dream-router routes) — dispatch on method+path |
Dream.get "/path" h |
route record {:method "GET" :path "/path" :handler h} |
Dream.scope "/p" [ms] [rs] |
prefix mount with middleware chain |
Dream.param req "name" |
path param extracted during routing |
m1 @@ m2 @@ handler |
(m1 (m2 handler)) — left-fold composition |
Dream.session_field req "k" |
(perform (:session-get req "k")) |
Dream.set_session_field req "k" v |
(perform (:session-set req "k" v)) |
Dream.flash req |
(perform (:flash-get req)) |
Dream.form req |
(perform (:form-parse req)) — returns Ok/Error ADT |
Dream.websocket handler |
(perform (:websocket handler)) |
Dream.run handler |
starts SX HTTP server with handler as root |
Roadmap
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: -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::namesegments,**wildcard. -dream-param req name— retrieve matched path param. - 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: - 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: -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: - [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. - 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'sperform (: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.
Stdlib additions Dream will need
Dream pushes beyond OCaml-on-SX's Phase 6 minimal stdlib slice. When this plan activates, OCaml-on-SX gets a follow-on phase that adds at minimum:
Bytes(binary buffers — request bodies, websocket frames)Buffer(mutable string building)Format(full pretty-printer, not justPrintf.sprintf)- More
String(index_opt,contains,starts_with,ends_with,replace_all) Sys(argv,getenv_opt,getcwd)Hashtblextensions (iter,fold,length,remove)Map.Make/Set.Makefunctors
Confirm scope before starting; some of these may be addable as Dream-internal helpers rather than full stdlib modules.
Ground rules
- Scope: only
lib/dream/**andplans/dream-on-sx.md. Plus the stdlib additions listed above which land inlib/ocaml/runtime.sx. - Hard prerequisite: OCaml-on-SX Phases 1–5 + Phase 6 minimal stdlib. Verify scoreboard before starting.
- SX files:
sx-treeMCP tools only. - Don't reinvent the SX HTTP server. Dream wraps the existing
perform (:http-listen ...)— it does not implement its own listener loop.
Progress log
- 2026-06-07 — Core types (
lib/dream/types.sx, 41 tests). OCaml gate verified green (scoreboard 480/480, Phases 1–5 + 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-responsewraps bare strings; query-string parsing. Conformance runnerlib/dream/conformance.shmodelled on flow's. - 2026-06-07 — Router (
lib/dream/router.sx, 27 tests).dream-get/post/put/ delete/patch/head/options/anyroute constructors;dream-routerflattens routes (incl. nested scopes) and dispatches by method+path, first-match-wins, 404 on no match. Path matching is recursive over/-split segments: literal,:namebinds a param,**catch-all binds remaining path under key"**". Trailing slashes and query strings are ignored for routing.dream-scope prefix mws routesprepends the prefix and folds the middleware chain (m1 @@ m2 @@ h, first = outermost) onto each route's handler; nests correctly (inner mw innermost). Shareddr/apply-middlewaresfold will backdream-pipeline. - 2026-06-07 — Middleware (
lib/dream/middleware.sx, 20 tests).dream-pipeline(reusesdr/apply-middlewares),dream-no-middlewareidentity.dream-logger-with clock sinkis the testable core (records{:method :path :status :elapsed});dream-loggerwires it to(perform (:dream-clock))/(perform (:dream-log …));dream-log-lineformats one line.dream-content-typesniffs body (<→html,{/[→json, else text) only when the handler left Content-Type unset. Bonusdream-set-headeranddream-tap-requestcombinators. - 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-sessionsis an in-memory store built on aset!-mutated capturedletbinding (noref/atomin base env);dream-perform-sessionsis the production back-end.dream-sessions backendmiddleware reads/creates the id, attaches{:sid :io}to the request, and emits aSet-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-cookieslist 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-flashmiddleware: decodes the incomingdream.flashcookie into the request, gives the handler a mutable outbox cell (dr/flash-box, the sameset!-captured-lettrick), 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-fieldsparsesapplication/x-www-form-urlencodedwith a full percent-decoder (%XXviachar-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 defaultdream-csrf-sign-defaultis a pure-SX dual-base polynomial keyed hash (NOT cryptographic — production should inject a host HMAC).dream-csrfattaches context (needs the session middleware upstream for the sid);dream-csrf-token/dream-csrf-tag(hidden input for templates);dream-formreturnsOk fieldsorErr :csrf-token-invalid;dream-csrf-protectauto-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 reqparsesmultipart/form-datainto parts{:name :filename :content-type :content}, returnsOk parts | Err :not-multipart. Needed a substring splitterdr/split-onbecause thesplitprimitive 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\ninto headers/content with one edge CRLF stripped (inner CRLFs in file content preserved).dream-multipart-field/dream-multipart-fileaccessors. In-memory, not streaming (noted for future).\r/\nstring escapes work in SX literals. - 2026-06-07 — WebSockets (
lib/dream/websocket.sx, 16 tests).dream-websocket handlerwraps a(fn (ws) …)into an ordinary handler returning a 101 upgrade response carrying the ws handler (dream-websocket?/dream-ws-handlerfor the host to detect + dispatch).dream-send/dream-receive/dream-close/dream-ws-open?/dream-ws-broadcastoperate over an injectable io; production io is(perform op), tests usedream-mock-ws(in-memory inbox/outbox/closed via the cell pattern) withdream-ws-sent/dream-ws-closed?introspection anddream-ws-runto drive a handler. Echo loop + room broadcast verified. - 2026-06-07 — Static files (
lib/dream/static.sx, 28 tests).dream-static rootmounts at a**route and serves files: content-type by extension (mime map), weak ETag ("hash-length") withIf-None-Match→ 304 (incl.*), andRange: bytes=requests → 206 withContent-Range(open-endedbytes=N-supported, unsatisfiable → 416).../absolute path traversal → 403; missing → 404; full responses advertiseAccept-Ranges. Filesystem is injectable —dream-static-perform-fs(host) vsdream-memory-fs(in-memory map for tests).
Blockers
(none — gate green, loop active)