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:
- Queries read state and return a view of it. They change nothing. Nothing! They are
GETs:
GET /drink-optionsreads$drinkand returns the drop-down. A query is safe to repeat, to retry and to prefetch, as many times as you like. - Commands change state. They are POST, PUT or DELETE: add a line to a cart, place an order, advance an order, toggle a milk. A command is not safe to repeat blindly (ask anyone who's been charged twice for one latte), and it needs the guard we add below.
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('/orders?for=x');alert(1);('')">
Hiccup escaped every quote, dutifully, and it didn't help one bit. Here's why. Escaping protects
the HTML attribute: the browser decodes ' 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.
- Server literals: an integer id, a keyword from a fixed set (
(name milk)for:oat),true/false. - URL-encoded values:
(ring.util.codec/url-encode customer)turnsx');alert(1);('intox%27%29%3Balert%281%29%3B%28%27, which can't end the string no matter how hard it tries. - Signal references:
@post('/orders')and let$customertravel in the body, or refer to an id the server owns and look the name up there.
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:
- Few GETs, no "data" endpoints. Pages, and queries that return patches. If you catch
yourself writing
GET /api/ordersreturning JSON for the page to render, stop. You are rebuilding the SPA, one endpoint at a time. Render the HTML on the server. - Commands are named for intent.
POST /orders/:id/advance, notPUT /orders/:idwith a status field. The server decides what "advance" means; the client can't send:status :picked-upand skip:making. - One pure state function. Handlers translate HTTP to commands. Rules live in
handle-command. - The guard is route data, applied to the command group, so a new command can't forget it.
- Responses are patches, and (from part 6) mostly not even that.
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-testHere'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.