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:
@@ -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
|
||||||
|
|||||||
@@ -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]]]))}))
|
||||||
|
|||||||
@@ -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
|
|
||||||
[:p.text-center.text-gray-400.text-sm "Loading..."]]]))
|
|
||||||
|
|
||||||
(defn price-fragment [{:keys [query-fn]}]
|
|
||||||
(let [row (query-fn :get-latest-binance-price {})]
|
|
||||||
(fragment {}
|
|
||||||
[: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
|
[:p.text-center.text-gray-400.text-sm "Connecting..."]]]]))
|
||||||
[:div.text-center
|
|
||||||
[:p.text-4xl.font-bold.text-gray-900
|
(defn- render-price-html [{:keys [price recorded-at]}]
|
||||||
(str "$" (format "%,.2f" (double (:price row))))]
|
(str
|
||||||
[:p.text-xs.text-gray-400.mt-2
|
(h/html
|
||||||
(str "Updated " (:recorded_at row))]]
|
[:div.bg-white.rounded-2xl.shadow-sm.border.border-gray-200.p-6
|
||||||
[:p.text-center.text-gray-400.text-sm "Waiting for data..."])])))
|
[:div.text-center
|
||||||
|
[:p.text-4xl.font-bold.text-gray-900
|
||||||
|
(str "$" (format "%,.2f" (double price)))]
|
||||||
|
[:p.text-xs.text-gray-400.mt-2
|
||||||
|
(str "Updated " recorded-at)]]])))
|
||||||
|
|
||||||
|
(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
|
||||||
|
|||||||
@@ -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)
|
||||||
:last-write 0
|
state (atom {:running? true
|
||||||
:buffer (StringBuilder.)
|
:last-write 0
|
||||||
:ws nil})
|
:buffer (StringBuilder.)
|
||||||
ws (connect! uri query-fn state)]
|
:ws nil})
|
||||||
|
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)]
|
||||||
|
|||||||
Reference in New Issue
Block a user