diff --git a/CLAUDE.md b/CLAUDE.md index d537d13..0c3c948 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ Docker runs alongside local dev on different ports and databases: ## API Endpoints - `GET /api/health` — Health check -- `GET /api/price/ws` — WebSocket endpoint for live JSON price updates +- `GET /api/price/ws` — WebSocket endpoint for live HTML price fragments (HTMX-compatible) - `GET /api/price/latest` — Latest price as JSON ## Source Layout diff --git a/README.md b/README.md index 64e2d08..2e6626c 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # btcdata -Bitcoin data backend service. Fetches BTC prices from Binance (WebSocket) and Kraken (HTTP polling), stores them in PostgreSQL, and exposes a JSON API with Server-Sent Events for live price streaming. +Bitcoin data backend service. Fetches BTC prices from Binance (WebSocket) and Kraken (HTTP polling), stores them in PostgreSQL, and exposes a JSON API with WebSocket for live price streaming. ## API | Endpoint | Description | |---|---| -| `GET /api/price/stream` | SSE stream — emits `price-update` events with JSON `{price, prev_price, recorded_at}` | +| `GET /api/price/ws` | WebSocket — sends HTML fragments with live price updates (HTMX-compatible) | | `GET /api/price/latest` | Latest price as JSON | | `GET /api/health` | Health check | diff --git a/src/clj/pmagnus/btcdata/web/routes/api.clj b/src/clj/pmagnus/btcdata/web/routes/api.clj index e336e95..b816b4f 100644 --- a/src/clj/pmagnus/btcdata/web/routes/api.clj +++ b/src/clj/pmagnus/btcdata/web/routes/api.clj @@ -9,11 +9,13 @@ [reitit.ring.coercion :as coercion] [reitit.ring.middleware.muuntaja :as muuntaja] [reitit.ring.middleware.parameters :as parameters] - [reitit.swagger :as swagger] - [ring.adapter.undertow.websocket :as ws]) + [reitit.swagger :as swagger]) (:import [io.undertow.server HttpServerExchange] - [io.undertow.websockets.core WebSockets WebSocketChannel] + [io.undertow.websockets WebSocketConnectionCallback WebSocketProtocolHandshakeHandler] + [io.undertow.websockets.core AbstractReceiveListener WebSockets WebSocketChannel] + [java.time Instant ZoneId] + [java.time.format DateTimeFormatter] [java.util.concurrent LinkedBlockingQueue TimeUnit])) (defn- price->json [{:keys [price prev-price recorded-at]}] @@ -22,32 +24,56 @@ :prev_price (when prev-price (str prev-price)) :recorded_at (str recorded-at)})) +(def ^:private ts-fmt (DateTimeFormatter/ofPattern "dd-MM HH:mm:ss")) + +(defn- price->html [{:keys [price prev-price recorded-at]}] + (let [p (double (bigdec price)) + pp (when prev-price (double (bigdec prev-price))) + color (cond + (nil? pp) "text-gray-900" + (> p pp) "text-green-600" + (< p pp) "text-red-600" + :else "text-gray-900") + ts (.format (.atZone (Instant/from recorded-at) (ZoneId/systemDefault)) ts-fmt)] + (str "
" + "
" + "
" + "

$" (format "%,.2f" p) "

" + "

Updated " ts "

" + "
"))) + +(defn- start-ws-send-loop! [^WebSocketChannel channel price-atom] + (let [queue (LinkedBlockingQueue.) + wkey (keyword (gensym "ws-"))] + (.set (.getReceiveSetter channel) + (proxy [AbstractReceiveListener] [])) + (.resumeReceives channel) + (add-watch price-atom wkey + (fn [_ _ _ v] (.offer queue v))) + (when-let [v @price-atom] + (.offer queue v)) + (future + (try + (loop [] + (when (.isOpen channel) + (if-let [v (.poll queue 15 TimeUnit/SECONDS)] + (WebSockets/sendTextBlocking (price->html v) channel) + (WebSockets/sendTextBlocking "" channel)) + (recur))) + (catch Exception _) + (finally + (remove-watch price-atom wkey) + (when (.isOpen channel) + (try (.close channel) (catch Exception _)))))))) + (defn- ws-price-handler [{:keys [binance]} req] (let [^HttpServerExchange exchange (:server-exchange req) - price-atom (:latest-price binance)] - (ws/ws-request exchange nil - (ws/ws-callback - {:on-open - (fn [{:keys [^WebSocketChannel channel]}] - (let [queue (LinkedBlockingQueue.) - wkey (keyword (gensym "ws-"))] - (add-watch price-atom wkey - (fn [_ _ _ v] (.offer queue v))) - (when-let [v @price-atom] - (.offer queue v)) - (future - (try - (loop [] - (when (.isOpen channel) - (if-let [v (.poll queue 15 TimeUnit/SECONDS)] - (WebSockets/sendTextBlocking (price->json v) channel) - (WebSockets/sendTextBlocking "{\"ping\":true}" channel)) - (recur))) - (catch Exception _) - (finally - (remove-watch price-atom wkey) - (when (.isOpen channel) - (try (.close channel) (catch Exception _))))))))})) + price-atom (:latest-price binance) + callback (proxy [WebSocketConnectionCallback] [] + (onConnect [_ws-exchange channel] + (start-ws-send-loop! channel price-atom))) + handler (WebSocketProtocolHandshakeHandler. callback)] + (.handleRequest handler exchange) nil)) (defn- latest-price-handler [{:keys [binance]} _req]