Datastar in Clojure Part 5 of 8
Part 5 · Shaping the backend

Shaping the backend

A Datastar backend has exactly two kinds of handler: page GETs that return a whole document, and Datastar requests that return patches. Split the second kind into queries and commands, keep the state change in one pure function, and the route table stays small and readable as the app grows. Which is not, historically speaking, what route tables do.

Let's take stock. Parts 1–4 added routes one at a time, like a kid adding rooms to a treehouse: /hello, /quote, /drink-options, /cart/items. Each handler read some input, maybe changed the atom, and returned the events for the elements it changed. That works fine for four routes. But Brew Board needs about a dozen, and two kinds of user (Ada at the counter, Sam behind it) who see the same orders. So before we nail on any more rooms, it's worth deciding what shape every handler should have. This part decides it. Then part 6 changes exactly one thing about that shape, and that one change is why the shape matters so much.

Two kinds of request

A browser asks a Datastar backend for things in two different ways, and they need different responses. Mix them up and you'll get some very confusing bug reports.

A page GET is a normal, old-fashioned navigation: the user typed /board or followed a link. There's no Datastar in the request at all. The browser wants a whole HTML document, so the response is text/html with <!DOCTYPE html>, the Datastar <script type="module"> tag, and the first render of the view. This is also where the sid cookie gets set, because it's the first request a new visitor makes.

A Datastar request comes from an action in an attribute: @get('/drink-options'), @post('/orders'). Datastar's fetch.ts sends it with fetch(), not a navigation, so the page stays right where it is. Every such request carries the header Datastar-Request: true and Accept: text/event-stream, text/html, application/json (the name tag from part 1), and it carries the signals: as URL-encoded JSON in the datastar query parameter for GET and DELETE, and as a JSON body for POST, PUT and PATCH. The response is a list of patches, usually as an SSE stream.

Now split the second kind again, this time by what the request does to the server:

If this smells familiar, it should. It's the same split as CQRS, just at the scale of one atom instead of a fleet of message queues and a consultant. It costs nothing to adopt, and it decides three things for you: the HTTP method, whether the route is guarded, and (in part 6) whether the route returns any HTML at all.

The route table

Parts 1–4 routed with a case on [request-method uri]. That was fine for four fixed paths, but it can't match /orders/:id/advance, and it has nowhere to say "these routes need a guard". The route table has outgrown its case, the way kids outgrow shoes, so this part switches to reitit, where routes are data. Here's Brew Board's table from lesson05.routes, with the two live streams and the native routes left out (they show up in parts 6 and 7):

(defn app [!state]
  (let [command (fn [f] {:handler f :middleware [wrap-datastar-request]})]
    (ring/ring-handler
      (ring/router
        [["/"                    {:get customer-page}]
         ["/board"               {:get board-page}]
         ["/drink-options"       {:get (partial drink-options !state)}]
         ["/cart/items"          {:post   (command (partial add-item !state))}]
         ["/cart/items/:idx"     {:delete (command remove-item)}]
         ["/orders"              {:post   (command place-order)}]
         ["/orders/:id/advance"  {:post   (command advance-order)}]
         ["/stock/:milk/toggle"  {:post   (command toggle-stock)}]])
      (ring/create-default-handler)
      {:middleware [wrap-params wrap-cookies wrap-sid]})))

Abridged: in the lesson the command handlers are short inline fns that build a command map and call run-command!. The little command helper puts the guard into each command route's data, so every command is guarded and no page or query is. (A reitit parent route with :middleware in its data would do the same for a group.)

Read the table top to bottom and you can see the whole app's surface area in one glance. That's rarer than it should be. Three GETs anyone can make. Five commands, all guarded. A command's path names the thing it acts on (/orders/:id) and ends in a verb when the HTTP method isn't enough (/advance, /toggle). And notice what's missing, because it's the interesting part. Nothing here is Datastar-specific: there's no /api prefix, no JSON endpoints, and no route that exists only to feed a client-side store. Routes return HTML, either a page or patches, and that's the hypermedia part.

Try each request below. The figure shows the bytes that arrive, which route matches, whether the guard passes, which inputs the handler actually reads, and what it sends back.

Inputs: path, cookie, signals

A Brew Board handler has three places to get input, and each one is right for a different thing. Using the wrong one is how you end up with a URL that has a user's shopping cart in it.

The path names the resource: :id in /orders/:id/advance, :milk in /stock/:milk/toggle. It's visible in logs, it's part of the URL the button calls, and it can't be changed by typing in a form field.

The cookie says who's asking. wrap-sid reads the sid cookie, or makes a fresh UUID on a page GET and sets it. Ada's cart is (get-in @state [:carts sid]). The browser sends the cookie on every same-origin fetch, so Datastar requests get it without you writing a single attribute.

The signals carry what the user typed or chose. Here's the reader, the same function part 3 built (old friends are the best friends):

(defn read-signals [{:keys [request-method query-params body]}]
  (let [raw    (if (#{:get :delete} request-method)
                 (get query-params "datastar")
                 (some-> body slurp))
        parsed (when-not (string/blank? raw)
                 (try (json/parse-string raw true) (catch Exception _ nil)))]
    (if (map? parsed) parsed {})))

Unparseable or non-object JSON gives {}, so a hand-made request reaches validation and gets a polite message, not a 500. The tests post not json to /cart/items and expect "Pick a drink."

Now, the important fact about signals is the one people get wrong, and they get it wrong confidently. Datastar does not send "the signals this action uses". It has no idea which signals an action uses, and it doesn't try to guess. The default filter in fetch.ts is include = /.*/, exclude = /(^|\.)_/: it sends every signal on the page whose name has no _-prefixed segment. Every one. Try it before you read on.

So a handler must pick the keys it uses and ignore the rest. place-order reads :customer and nothing else. add-item reads :drink :size :milk :qty, and then checks them against the menu and the sold-out set. That check is not optional, and I can't stress this enough. Signals are input, as untrusted as a form post, and a user can set any of them from the browser console in about four seconds. Using select-keys plus a validator, as add-to-cart did in part 4, is the habit to keep.

You can narrow what gets sent per action, e.g. @post('/orders', {filterSignals: {include: /^customer$/}}). It cuts bytes, and it's worth doing when a page has a large signal you never want on the wire. But it is not a security boundary, so don't let it lull you. The server still validates whatever arrives.

A pure core: handle-command

Every command does the same three things: read its inputs from the request, change the state, and respond. Only the middle step is actually about coffee. The other two are plumbing. So put the coffee part in one pure function, and keep it far away from the plumbing:

(defn handle-command [state {:keys [type sid line idx customer id milk]}]
  (case type
    :add-to-cart      (cond-> state
                        (nil? (line-error state line)) (update-in [:carts sid] (fnil conj []) line))
    :remove-from-cart (update-in state [:carts sid] drop-index idx)
    :place-order      (let [lines (get-in state [:carts sid])]
                        (if (or (empty? lines) (string/blank? customer))
                          state
                          (-> state
                              (assoc-in [:orders (:next-id state)]
                                        {:id (:next-id state) :sid sid :customer customer
                                         :status :placed :lines lines})
                              (update :next-id inc)
                              (update :carts dissoc sid))))
    :advance          (advance state id)        ;; :placed → :making → :ready → gone
    :toggle-stock     (update state :sold-out #(if (% milk) (disj % milk) (conj % milk)))
    state))

Slightly condensed from lesson05.routes. A command that doesn't apply (an invalid line, an empty cart, an unknown order) returns the state unchanged. No exceptions, no drama.

The handler is now thin: turn the request into a command map, then run-command!, which is (command-response (swap! !state handle-command cmd) cmd). A pure function of state and command is easy to test (call it with a map, compare the result) and easy to read, because all of Brew Board's business rules live in one case. One! You can read every rule in the app without scrolling. And here's the part to remember: it's also the piece that does not change in part 6. Only the response changes.

Validation sits just before the swap, and it has a Datastar-specific wrinkle. Datastar applies the body only of a 200 response. In fetch.ts, any other status skips the stream parser: a 204 or a redirect ends quietly, and a 4xx or 5xx fires a datastar-fetch error event and patches nothing (and with the default retry: 'auto', it isn't retried). Which means your instinct to send a 422 is going to produce a page that does absolutely nothing. So "you can't order oat milk, it's sold out" is a 200 whose events patch an error message into the page, as HTML: add-item answers a refused line with <p id="draft-error">Oat milk is sold out.</p> and a good one with the new #cart plus an empty #draft-error. No signal holds the message. A 4xx is for requests no honest page would ever send.

(defn add-item [!state {:keys [sid] :as req}]
  (let [line (draft-line (sse/read-signals req))]
    (if-let [err (line-error @!state line)]
      (sse/sse-response (sse/patch-elements (html [:p#draft-error err])))
      (update (run-command! !state {:type :add-to-cart :sid sid :line line})
              :body str (sse/patch-elements (html [:p#draft-error]))))))

Responding: fragment per action

In this part, each command answers with the patches for whatever it changed. That's command-response: given the new state and the command, return the response. For add-to-cart that means the new #cart. For place-order it means the empty #cart, the new entry in #my-orders, and a patch-signals that clears $customer:

HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache

event: datastar-patch-elements
data: elements <section id="cart">…<p class="muted">Empty.</p>…</section>

event: datastar-patch-elements
data: elements <section id="my-orders"><h2>Your orders</h2><ul><li>#1 for Ada: placed</li></ul></section>

event: datastar-patch-signals
data: signals {"customer":""}

Tip. When the server re-renders an element anyway, let it decide disabled too. cart-view renders "Place order" with disabled while the cart is empty, and every cart command re-sends #cart, so the button is always right, for free. A client expression such as data-attr:disabled="$cartCount == 0" would need a count signal that mirrors server state, and you know how part 4 feels about mirrors.

This is the natural first design, and it's the one you'd write coming from htmx. There's nothing dumb about it. But it also has a cost, and the cost grows with the app like a slow leak: each command has to know which parts of which pages show the thing it changed. place-order knows about #cart and #my-orders. Now add an order count to the header. You must remember to add it to place-order's response, and to advance-order's, and to every other command that changes orders. Forget one and that view is stale until a reload. And you will forget one. Everybody does.

And there's a worse gap hiding behind that one. The response goes to the browser that sent the request. Only that browser. Sam has /board open in another browser. So: what does Sam see when Ada orders?

The second mode in the figure is where part 6 goes, and it's going to feel like cheating. Commands answer 204 No Content and do nothing but swap!. Every open page holds one long-lived GET, and when the atom changes, the server renders each page's whole view again and sends it down that stream. No command knows about any view, and every viewer is current. Now, I know what you're thinking. It sounds expensive, and it sounds like it would wreck focus and typed text. Part 6 shows why it does neither.

Guarding commands

A command changes state using the caller's cookie, which makes it a CSRF target. A page on evil.example could make Ada's browser post to /orders, and the browser, trying to be helpful, would attach Ada's cookie. Brew Board uses a cheap guard:

(defn wrap-datastar-request [handler]
  (fn [req]
    (if (= "true" (get-in req [:headers "datastar-request"]))
      (handler req)
      {:status 403 :headers {"Content-Type" "text/plain"} :body "Datastar requests only"})))

Why does this help? Because a plain HTML form on another site can POST cross-origin, but it can't set a custom header. A cross-origin fetch that sets Datastar-Request is not a "simple request", so the browser first sends a CORS preflight, and our server doesn't answer preflights with permission, so the real request is never sent. The attacker's page asks "may I?" and gets silence. (Ring lowercases header names, hence "datastar-request".)

But let's be precise about what this is, because security is the one place where vagueness gets people hurt. It is a partial defense. It relies on the server never adding permissive CORS headers, and it's best paired with SameSite=Lax (or Strict) on the session cookie, which stops the browser attaching the cookie to cross-site POSTs at all. A real app adds a CSRF token as well: render it into a signal on the page GET (for instance data-signals:csrf, not _-prefixed, because it has to be sent), and check it in this same middleware. The toy stops at the header. It's a toy.

Expressions are JavaScript (or: escaping won't save you)

The board renders one button per order, with the order's id inside the expression: (str "@post('/orders/" id "/advance')"). That's safe, because id is an integer the server assigned, and integers are famously bad at JavaScript injection. Now suppose a "reorder" button carried the customer's name the same way:

;; WRONG: a stored value inside an expression
[:button {:data-on:click (str "@post('/orders?for=" customer "')")} "Reorder"]

A customer who typed their name as x');alert(1);(' (there's always one) gets this HTML:

<button data-on:click="@post(&apos;/orders?for=x&apos;);alert(1);(&apos;&apos;)">

Hiccup escaped every quote, dutifully, and it didn't help one bit. Here's why. Escaping protects the HTML attribute: the browser decodes &apos; back to ' when it parses the page, and Datastar reads the decoded value from el.dataset (applyEls in engine.ts). So what Datastar evaluates is @post('/orders?for=x');alert(1);(''): three JavaScript statements, the second of them chosen by the customer. That's stored XSS, and it fires for whoever clicks Sam's button. Which is Sam. Poor Sam.

So here's the rule: put only three kinds of thing inside an expression string.

Never a raw stored or request value. Never. The same thinking covers the other two places where values meet code. Seed signals with cheshire ({:data-signals (json/generate-string m)}): JSON is a JavaScript literal and cheshire escapes the quotes inside strings, which hand-built (str "{name: '" name "'}") doesn't. And JSON inside an inline <script> must write every < as \u003c, or a value containing </script> ends the tag early; part 7's bridge-script does this.

htmx marks its requests with HX-Request: true and expects HTML fragments back, swapped by hx-target/hx-swap attributes on the client. Datastar puts the target in the response instead: the element's id, or a selector and mode in the event. That sounds like a small difference. It isn't. It moves "which part of the page does this update?" from markup to the server, and that's exactly what makes part 6's whole-view render possible.

What a good route table looks like

If you remember nothing else from this part, remember this list:

The code is in clojure/src/lesson05/routes.clj (ns lesson05.routes), with tests in clojure/test/lesson05/routes_test.clj. The demo starts the server, sends each request from the figure (including the forged one without the header, so you can watch it bounce), and prints the status and the raw SSE that comes back: cd clojure && clojure -M -m lesson05.routes. At the REPL, run (lesson05.routes/start! 8080) and open http://localhost:8080/ and /board side by side to see the stale board for yourself. Place an order. Look at Sam's screen. Feel Sam's pain.

Your turn. The exercise track for this part has three functions: handle-command (the pure state transition for all five commands), wrap-datastar-request (the guard) and command-response (which patches each command sends back). Hints are on the exercises page.

cd clojure && clojure -M:ex -d ex -n exercises.lesson05.routes-test

Here's the whole part in one breath. Pages are GETs that return documents. Datastar requests split into queries (GET, change nothing) and commands (POST/PUT/DELETE, guarded, change state through one pure handle-command). Signals arrive all at once, so pick the keys you use and validate them. Answering each command with the patches it caused works, but only for the browser that asked. That gap, the one Sam keeps falling into, is what part 6 closes.