Switch price updates from HTMX polling to SSE

Replace 2s polling with Server-Sent Events for instant price updates.
Write directly to Undertow exchange output stream to ensure proper
flushing, since the Ring adapter buffers InputStream bodies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 16:09:47 +01:00
co-authored by Claude Opus 4.6
parent a50256a042
commit 08793c5099
4 changed files with 82 additions and 32 deletions
+2 -1
View File
@@ -51,7 +51,8 @@
:reitit.routes/ui :reitit.routes/ui
{:base-path "" {:base-path ""
:query-fn #ig/ref :db.sql/query-fn} :query-fn #ig/ref :db.sql/query-fn
:binance #ig/ref :ws/binance}
:ws/binance :ws/binance
{:query-fn #ig/ref :db.sql/query-fn {:query-fn #ig/ref :db.sql/query-fn
+2 -1
View File
@@ -18,7 +18,8 @@
[:meta {:name "apple-mobile-web-app-status-bar-style" :content "black-translucent"}] [:meta {:name "apple-mobile-web-app-status-bar-style" :content "black-translucent"}]
[:title (or (:title opts#) "BTC Price")] [:title (or (:title opts#) "BTC Price")]
[:link {:rel "stylesheet" :href "/css/output.css"}] [:link {:rel "stylesheet" :href "/css/output.css"}]
[:script {:src "https://unpkg.com/htmx.org@2.0.8"}]] [:script {:src "https://unpkg.com/htmx.org@2.0.8"}]
[:script {:src "https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"}]]
[:body [:body
[:div.mx-auto.max-w-lg.px-4.py-6 [:div.mx-auto.max-w-lg.px-4.py-6
~@content]]]))})) ~@content]]]))}))
+56 -14
View File
@@ -1,11 +1,18 @@
(ns pmagnus.btcprice.web.routes.ui (ns pmagnus.btcprice.web.routes.ui
(:require (:require
[clojure.string :as str]
[hiccup2.core :as h]
[integrant.core :as ig] [integrant.core :as ig]
[pmagnus.btcprice.web.htmx :refer [page fragment]] [pmagnus.btcprice.web.htmx :refer [page]]
[pmagnus.btcprice.web.middleware.exception :as exception] [pmagnus.btcprice.web.middleware.exception :as exception]
[pmagnus.btcprice.web.middleware.formats :as formats] [pmagnus.btcprice.web.middleware.formats :as formats]
[reitit.ring.middleware.muuntaja :as muuntaja] [reitit.ring.middleware.muuntaja :as muuntaja]
[reitit.ring.middleware.parameters :as parameters])) [reitit.ring.middleware.parameters :as parameters])
(:import
[io.undertow.server HttpServerExchange]
[io.undertow.util HttpString]
[java.io BufferedWriter OutputStreamWriter]
[java.util.concurrent LinkedBlockingQueue TimeUnit]))
(defn home-page [_req] (defn home-page [_req]
(page {:title "BTC Price"} (page {:title "BTC Price"}
@@ -13,28 +20,63 @@
[:h1.text-3xl.font-bold.text-gray-900 "BTC Price"] [:h1.text-3xl.font-bold.text-gray-900 "BTC Price"]
[:p.text-sm.text-gray-500.mt-1 "Bitcoin price tracker"]] [:p.text-sm.text-gray-500.mt-1 "Bitcoin price tracker"]]
[:div#price-panel.space-y-4 [:div#price-panel {:hx-ext "sse" :sse-connect "/price/stream"}
{:hx-get "/price" :hx-trigger "every 2s" :hx-swap "innerHTML"} [:div {:sse-swap "price-update"}
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6 [:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
[:p.text-center.text-gray-400.text-sm "Loading..."]]])) [:p.text-center.text-gray-400.text-sm "Connecting..."]]]]))
(defn price-fragment [{:keys [query-fn]}] (defn- render-price-html [{:keys [price recorded-at]}]
(let [row (query-fn :get-latest-binance-price {})] (str
(fragment {} (h/html
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6 [:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
(if row
[:div.text-center [:div.text-center
[:p.text-4xl.font-bold.text-gray-900 [:p.text-4xl.font-bold.text-gray-900
(str "$" (format "%,.2f" (double (:price row))))] (str "$" (format "%,.2f" (double price)))]
[:p.text-xs.text-gray-400.mt-2 [:p.text-xs.text-gray-400.mt-2
(str "Updated " (:recorded_at row))]] (str "Updated " recorded-at)]]])))
[:p.text-center.text-gray-400.text-sm "Waiting for data..."])])))
(defn- format-sse [event data]
(str "event: " event "\n"
(str/join "\n" (map #(str "data: " %) (str/split-lines data)))
"\n\n"))
(defn- price-stream-handler [{:keys [binance]} req]
(let [^HttpServerExchange exchange (:server-exchange req)
price-atom (:latest-price binance)
headers (.getResponseHeaders exchange)]
(.setStatusCode exchange 200)
(.put headers (HttpString. "Content-Type") "text/event-stream")
(.put headers (HttpString. "Cache-Control") "no-cache")
(.put headers (HttpString. "X-Accel-Buffering") "no")
(when-not (.isBlocking exchange)
(.startBlocking exchange))
(let [out (.getOutputStream exchange)
writer (BufferedWriter. (OutputStreamWriter. out "UTF-8"))
queue (LinkedBlockingQueue.)
wkey (keyword (gensym "sse-"))]
(add-watch price-atom wkey
(fn [_ _ _ v] (.offer queue v)))
(when-let [v @price-atom]
(.offer queue v))
(try
(loop []
(if-let [v (.poll queue 15 TimeUnit/SECONDS)]
(do (.write writer (format-sse "price-update" (render-price-html v)))
(.flush writer))
(do (.write writer ": heartbeat\n\n")
(.flush writer)))
(recur))
(catch Exception _)
(finally
(remove-watch price-atom wkey)
(try (.close writer) (catch Exception _)))))
nil))
(defn- ui-routes [opts] (defn- ui-routes [opts]
[["/" [["/"
{:get home-page}] {:get home-page}]
["/price" ["/price/stream"
{:get (fn [_req] (price-fragment opts))}]]) {:get (fn [req] (price-stream-handler opts req))}]])
(defn route-data [opts] (defn route-data [opts]
(merge (merge
+14 -8
View File
@@ -16,8 +16,9 @@
(defn- connect! (defn- connect!
"Opens a WebSocket to Binance trade stream and returns the WebSocket instance. "Opens a WebSocket to Binance trade stream and returns the WebSocket instance.
`state` is an atom with keys :running?, :last-write, :buffer." `state` is an atom with keys :running?, :last-write, :buffer.
[uri query-fn state] `latest-price` is an atom reset with each throttled price for SSE consumers."
[uri query-fn state latest-price]
(let [client (HttpClient/newHttpClient) (let [client (HttpClient/newHttpClient)
listener (reify WebSocket$Listener listener (reify WebSocket$Listener
(onOpen [_ ws] (onOpen [_ ws]
@@ -38,7 +39,10 @@
(when (> (- now (:last-write @state)) 5000) (when (> (- now (:last-write @state)) 5000)
(swap! state assoc :last-write now) (swap! state assoc :last-write now)
(log/info "BTC price:" (str price)) (log/info "BTC price:" (str price))
(save-price! query-fn price))))) (save-price! query-fn price)
(reset! latest-price
{:price price
:recorded-at (java.time.Instant/now)})))))
(catch Exception e (catch Exception e
(log/error e "Failed to parse Binance message"))))) (log/error e "Failed to parse Binance message")))))
(let [^CompletionStage cf (CompletableFuture/completedFuture nil)] (let [^CompletionStage cf (CompletableFuture/completedFuture nil)]
@@ -53,7 +57,7 @@
(when (:running? @state) (when (:running? @state)
(log/info "Reconnecting to Binance...") (log/info "Reconnecting to Binance...")
(try (try
(let [ws (connect! uri query-fn state)] (let [ws (connect! uri query-fn state latest-price)]
(swap! state assoc :ws ws)) (swap! state assoc :ws ws))
(catch Exception e (catch Exception e
(log/error e "Binance reconnect failed"))))))) (log/error e "Binance reconnect failed")))))))
@@ -67,16 +71,18 @@
(defmethod ig/init-key :ws/binance (defmethod ig/init-key :ws/binance
[_ {:keys [query-fn uri]}] [_ {:keys [query-fn uri]}]
(log/info "Starting Binance WebSocket listener:" uri) (log/info "Starting Binance WebSocket listener:" uri)
(let [state (atom {:running? true (let [latest-price (atom nil)
state (atom {:running? true
:last-write 0 :last-write 0
:buffer (StringBuilder.) :buffer (StringBuilder.)
:ws nil}) :ws nil})
ws (connect! uri query-fn state)] ws (connect! uri query-fn state latest-price)]
(swap! state assoc :ws ws) (swap! state assoc :ws ws)
state)) {:state state
:latest-price latest-price}))
(defmethod ig/halt-key! :ws/binance (defmethod ig/halt-key! :ws/binance
[_ state] [_ {:keys [state]}]
(log/info "Stopping Binance WebSocket listener") (log/info "Stopping Binance WebSocket listener")
(swap! state assoc :running? false) (swap! state assoc :running? false)
(when-let [ws (:ws @state)] (when-let [ws (:ws @state)]