|
| 1 | +^:kindly/hide-code |
| 2 | +(ns scicloj.clay.webserver.datastar |
| 3 | + {:kindly/servable true |
| 4 | + :clay {:title "Serving webapps from your REPL" |
| 5 | + :quarto {:author :timothypratley |
| 6 | + :description "Using Clay's new webserver features and Datastar to build a chart with realtime server-push updates" |
| 7 | + :draft true |
| 8 | + :reference-location :margin |
| 9 | + :citation-location :margin |
| 10 | + :type :post |
| 11 | + :date "2026-01-10" |
| 12 | + :category :clay |
| 13 | + :tags [:clay :workflow]}}} |
| 14 | + (:require [hiccup.core :as hiccup])) |
| 15 | + |
| 16 | +;; Clay converts a Clojure namespace into an HTML page and runs a web server to display it. |
| 17 | +;; The Clay server can do more than just serve static pages: we can define |
| 18 | +;; endpoints that handle requests and dynamically render content. |
| 19 | +;; |
| 20 | +;; This page demonstrates a server-driven web application using Datastar. |
| 21 | +;; The server manages both the rendering logic and the application state, while the browser handles |
| 22 | +;; user interactions and displays the results. |
| 23 | +;; |
| 24 | +;; > "The cosmos is within us. We are made of star-stuff. We are a way for the universe to know itself." |
| 25 | +;; > |
| 26 | +;; > — Carl Sagan |
| 27 | +;; |
| 28 | +;; All the code is in this namespace. |
| 29 | +;; The server is in your REPL. |
| 30 | + |
| 31 | +;; ## First light from the REPL |
| 32 | + |
| 33 | +;; I'll send this namespace to Clay by calling the "Clay: Make File Quarto" command in Calva. |
| 34 | +;; If you want to follow along, you can clone this repo and do the same. |
| 35 | +;; (Let's start stargazing.) |
| 36 | +;; |
| 37 | +;; Or if you prefer to watch first, here's a screencast: |
| 38 | + |
| 39 | +^:kind/video ^:kindly/hide-code |
| 40 | +{:youtube-id "k98QI-EOHJA" |
| 41 | + :iframe-width "100%"} |
| 42 | + |
| 43 | +;; ## The constellation of reactions |
| 44 | + |
| 45 | +;; Here's the application: a reaction counter with emoji buttons. |
| 46 | +;; When we click the buttons, the chart updates in real time, showing the count for each emoji. |
| 47 | +;; Notice how if we open this page in multiple browser tabs, the counts update across all tabs |
| 48 | +;; simultaneously—this is the server pushing updates to all connected clients. |
| 49 | + |
| 50 | +^:kind/hiccup |
| 51 | +[:div.d-flex.justify-content-center.my-5 |
| 52 | + [:div.card.shadow {:style {:width "260px"}} |
| 53 | + [:div.card-body |
| 54 | + ;; "reactions" is a target for replacement |
| 55 | + [:div {:id "reactions" |
| 56 | + :data-init "@get('/kindly-compute/scicloj.clay.webserver.datastar/react-html');"}] |
| 57 | + ;; buttons that call the backend with an emoji |
| 58 | + [:div.d-flex.gap-2.mt-3 |
| 59 | + [:button.btn.btn-outline-primary {:data-on:click "@post('/kindly-compute/scicloj.clay.webserver.datastar/react-html?emoji=👍');"} "👍"] |
| 60 | + [:button.btn.btn-outline-primary {:data-on:click "@post('/kindly-compute/scicloj.clay.webserver.datastar/react-html?emoji=🎉');"} "🎉"] |
| 61 | + [:button.btn.btn-outline-primary {:data-on:click "@post('/kindly-compute/scicloj.clay.webserver.datastar/react-html?emoji=❤️');"} "❤️"]]]]] |
| 62 | + |
| 63 | +;; ::: {.callout-note} |
| 64 | +;; The buttons won't do anything unless you are running the Clay server. |
| 65 | +;; To do that, clone this repo and run [Clay make](https://scicloj.github.io/clay/#setup) on this file. |
| 66 | +;; ::: |
| 67 | + |
| 68 | +;; When we click a button, here's what happens: |
| 69 | + |
| 70 | +;; 1. The browser makes an HTTP request to the server |
| 71 | +;; 2. The server updates its state and renders new HTML |
| 72 | +;; 3. Datastar receives the HTML and patches it into the page |
| 73 | +;; |
| 74 | +;; Datastar is a JavaScript library that intelligently updates the DOM without full page reloads. |
| 75 | +;; Rather than writing JavaScript callbacks, Datastar behaviors are simply data attributes in HTML. |
| 76 | +;; The `:data-on:click` attribute on our buttons tells Datastar which endpoint to call. |
| 77 | +;; |
| 78 | +;; This approach is ideal for Clay, where we naturally express UI as Hiccup data structures. |
| 79 | +;; The server owns both the state and the rendering logic, so we can reason about the entire |
| 80 | +;; application in Clojure, but still have interactive, responsive UI on the browser. |
| 81 | + |
| 82 | +^:kind/hiccup |
| 83 | +[:script |
| 84 | + {:src "https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.7/bundles/datastar.js" |
| 85 | + :type "module"}] |
| 86 | + |
| 87 | +;; Datastar is a small JavaScript library that adds interactivity to our HTML. |
| 88 | +;; It watches for `data-*` attributes in the DOM and responds to user interactions by making HTTP requests to our server. |
| 89 | + |
| 90 | +;; ## State at the stellar core |
| 91 | + |
| 92 | +;; We keep track of reactions in an atom: |
| 93 | + |
| 94 | +(defonce reactions |
| 95 | + (atom {"👍" 8, "🎉" 4, "❤️" 2})) |
| 96 | + |
| 97 | +;; This state lives on the server in the REPL process, not in the browser. |
| 98 | +;; It's the central source of truth.[^state] |
| 99 | +;; We can modify it from the REPL to test or debug the application. |
| 100 | +;; Clients request this state from the server. |
| 101 | +;; |
| 102 | +;; [^state]: **State** is the current data that defines how the application looks and behaves. |
| 103 | + |
| 104 | +;; ## Charting star reactions |
| 105 | + |
| 106 | +;; (Now to illuminate the reactions.) |
| 107 | +;; |
| 108 | +;; Let's create a hiccup representation of the chart: |
| 109 | + |
| 110 | +(defn chart [rs] |
| 111 | + (let [maxv (max 1 (apply max (vals rs))) |
| 112 | + bar (fn [[label n]] |
| 113 | + [:div {:style {:display "flex" :alignItems "center" :gap "0.5rem"}} |
| 114 | + [:span label] |
| 115 | + [:div {:style {:background "#eee" :width "150px" :height "8px"}} |
| 116 | + [:div {:style {:background "#4ECDC4" |
| 117 | + :height "8px" |
| 118 | + :width (str (int (* 100 (/ n maxv))) "%")}}]] |
| 119 | + [:span n]])] |
| 120 | + [:div {:id "reactions"} |
| 121 | + [:h3 "Reactions"] |
| 122 | + (into [:div {:style {:display "grid" :gap "0.25rem"}}] |
| 123 | + (map bar rs))])) |
| 124 | + |
| 125 | +;; The top-level `div` has `id=\"reactions\"`. |
| 126 | +;; This is crucial: Datastar uses element IDs to match the updated HTML with the existing DOM element, then morphs the DOM in place.[^morph] |
| 127 | +;; When Datastar receives new HTML, it looks for matching IDs and updates the content gracefully. |
| 128 | +;; |
| 129 | +;; [^morph]: **Morph** means to smoothly transform the DOM structure without destroying and recreating elements, preserving animations and focus. |
| 130 | + |
| 131 | +;; ## Signals from the cosmos |
| 132 | + |
| 133 | +;; We need the Clay server to listen for the Datastar request: |
| 134 | + |
| 135 | +(defn ^:kindly/servable react-html [{:keys [emoji]}] |
| 136 | + (-> (if emoji |
| 137 | + (swap! reactions update emoji (fnil inc 0)) |
| 138 | + @reactions) |
| 139 | + (chart) |
| 140 | + (hiccup/html))) |
| 141 | + |
| 142 | +;; The `:kindly/servable` metadata marks this function as a remote endpoint that Clay will expose.[^servable-feature] |
| 143 | +;; The function is called with the emoji parameter from the URL, updates the shared state atom, |
| 144 | +;; and returns the new chart as HTML. |
| 145 | +;; (Each click is a signal received.) |
| 146 | +;; |
| 147 | +;; [^servable-feature]: See the [Clay documentation](https://scicloj.github.io/clay/clay_book.webserver.html) for details on `:kindly/servable` endpoints. |
| 148 | + |
| 149 | +;; ::: {.callout-note} |
| 150 | +;; **Naming convention matters:** The function name ends in `html`. |
| 151 | +;; Clay uses this naming convention to determine whether to serve the result as HTML (not JSON). |
| 152 | +;; This keeps the response as a raw HTML fragment that Datastar can inject directly into the page. |
| 153 | +;; ::: |
| 154 | +;; |
| 155 | +;; When the browser receives this HTML, Datastar finds the element with `id="reactions"` in both |
| 156 | +;; the old and new HTML, and morphs the DOM in-place. |
| 157 | +;; This means animations and focus are preserved rather than destroying and recreating the entire element. |
| 158 | + |
| 159 | +;; ## Broadcasting to the cosmos |
| 160 | + |
| 161 | +;; So far the client pulls data when a button is clicked. |
| 162 | +;; What if we want the server to push updates to all connected clients whenever the state changes? |
| 163 | +;; (Broadcasting to all the stars.)[^sse] |
| 164 | +;; For that we need a channel from server to client: Server Sent Events (SSE). |
| 165 | +;; The [Datastar Clojure SDK](https://github.com/starfederation/datastar-clojure) |
| 166 | +;; provides helpers to set up and manage these push connections. |
| 167 | +;; |
| 168 | +;; [^sse]: **Server Sent Events (SSE)** is a standard for opening a one-way channel from server to client, perfect for pushing real-time updates. |
| 169 | + |
| 170 | +;; We track all open SSE connections in a set. |
| 171 | +;; When a client connects or disconnects, we add or remove it from this set. |
| 172 | + |
| 173 | +(defonce *ds-connections |
| 174 | + (atom #{})) |
| 175 | + |
| 176 | +;; This handler creates an SSE connection. |
| 177 | +;; The `:kindly/handler` metadata tells Clay to expose this function as an HTTP endpoint that takes a request and returns a response.[^handler-feature] |
| 178 | +;; The `->sse-response` helper returns the proper SSE response structure. |
| 179 | +;; When a client connects, we store its SSE generator. |
| 180 | +;; When it closes, we remove it. |
| 181 | +;; |
| 182 | +;; [^handler-feature]: This `:kindly/handler` feature is available in Clay 2.0.4 and later. See the [Clay documentation](https://scicloj.github.io/clay/clay_book.webserver.html) for details. |
| 183 | + |
| 184 | +(require '[starfederation.datastar.clojure.adapter.http-kit :as d*a]) |
| 185 | + |
| 186 | +(defn ^:kindly/handler datastar [req] |
| 187 | + (d*a/->sse-response |
| 188 | + req |
| 189 | + {d*a/on-open (fn [sse-gen] (swap! *ds-connections conj sse-gen)) |
| 190 | + d*a/on-close (fn [sse-gen _status] (swap! *ds-connections disj sse-gen))})) |
| 191 | + |
| 192 | +;; Broadcast sends HTML to all connected clients. |
| 193 | +;; Datastar on the browser side will receive these patches and update the DOM. |
| 194 | + |
| 195 | +(require '[starfederation.datastar.clojure.api :as d*]) |
| 196 | + |
| 197 | +(defn broadcast-elements! [elements] |
| 198 | + (doseq [c @*ds-connections] |
| 199 | + (d*/patch-elements! c elements {:id "reactions"}))) |
| 200 | + |
| 201 | +;; Now we create a watch callback that will broadcast the chart whenever the state changes: |
| 202 | + |
| 203 | +(defn broadcast-chart! [_k _r _a b] |
| 204 | + (broadcast-elements! (hiccup/html (chart b)))) |
| 205 | + |
| 206 | +;; Attach a watch to the reactions atom. |
| 207 | +;; Whenever the atom changes, this callback fires, renders the new chart as HTML, and broadcasts it to all connected clients. |
| 208 | + |
| 209 | +(defonce watch-reactions |
| 210 | + (add-watch reactions :chart #'broadcast-chart!)) |
| 211 | + |
| 212 | +;; Clojure watches are a built-in mechanism for reacting to state changes.[^watch] |
| 213 | +;; They take a reference type (atom, ref, agent, etc.) and a callback function that fires |
| 214 | +;; whenever that reference is modified. |
| 215 | +;; This is how we push updates to all clients automatically. |
| 216 | +;; |
| 217 | +;; [^watch]: **Watch** is a Clojure mechanism that lets you react to changes in a reference type by calling a function whenever it changes. |
| 218 | + |
| 219 | +;; For example, we can test this from the REPL: |
| 220 | + |
| 221 | +(comment |
| 222 | + (swap! reactions update "👍" inc)) |
| 223 | + |
| 224 | +;; When you evaluate this, the watch callback fires, broadcasts the updated chart as HTML, and all open browser windows receive and display the updated chart immediately. |
| 225 | +;; This demonstrates the full loop: REPL state change → watch callback → broadcast to clients → DOM updates in real time. |
| 226 | + |
| 227 | +;; Now our page should start listening to the SSE stream: |
| 228 | + |
| 229 | +^:kind/hiccup |
| 230 | +[:div {:data-init "@get('/kindly-compute/scicloj.clay.webserver.datastar/datastar')"}] |
| 231 | + |
| 232 | +;; The `data-init` field tells Datastar to connect to the Server Sent Events handler, |
| 233 | +;; and listen for subsequent messages. |
| 234 | +;; SSE (Server Sent Events) is ideal here because we only need one-way communication from server to clients. |
| 235 | +;; Unlike WebSockets, SSE uses standard HTTP and requires minimal setup. |
| 236 | +;; Unlike polling, it's efficient—the server pushes updates only when the state changes. |
| 237 | + |
| 238 | +;; Now when we open multiple pages and click in one, |
| 239 | +;; we can see the data update in all the pages. |
| 240 | +;; (A cosmos of observers.) |
| 241 | + |
| 242 | +;; ## Reflecting on the skies |
| 243 | + |
| 244 | +;; All the code for this page is written in Clojure. |
| 245 | +;; The entire application—state management, rendering, and interactivity—lives in Clojure, not split between server and client JavaScript. |
| 246 | +;; |
| 247 | +;; This style of web development can be very productive. |
| 248 | +;; Clay makes it fast to prototype rich, stateful applications using only Clojure and server-side rendering. |
| 249 | +;; Datastar eliminates the need for client-side state management and event handling. |
| 250 | +;; (One star, many observers.) |
| 251 | +;; The result: you reason about the whole application in one language and get interactive, |
| 252 | +;; responsive UX without touching JavaScript. |
| 253 | +;; (And so we return to the stars.) |
| 254 | +;; |
| 255 | +;; > "For small creatures such as we, the vastness is bearable only through love." |
| 256 | +;; > |
| 257 | +;; > — Carl Sagan |
| 258 | +;; |
| 259 | +;; If you love Clojure, I think you'll love Clay's new server features. |
0 commit comments