feat: add SSE live sync between clients
When any client pushes changes, the server broadcasts an SSE event to all connected clients watching that group. Clients immediately pull the latest data — no polling delay, no page reload. Server (~30 lines): - GET /events?group=G — SSE endpoint, holds connection open - On successful POST /sync, notify all SSE listeners for affected groups - Auth-aware: respects token and group permissions - Auto-cleanup on disconnect via http-kit on-close Client (~25 lines): - EventSource connects to /events?group=G on sync loop start - On SSE message, triggers do-pull! to fetch latest data - Auto-reconnects (browser EventSource built-in behavior) - Cleanup on destroy! Periodic polling remains as fallback (30s default). SSE provides sub-second sync for the common case.
This commit is contained in:
@@ -49,6 +49,33 @@
|
||||
(or (nil? (:groups user)) ;; nil = access to all groups
|
||||
(contains? (:groups user) group)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; SSE — live change notifications
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defonce ^:private sse-clients
|
||||
;; {group -> #{http-kit-channel ...}}
|
||||
(atom {}))
|
||||
|
||||
(defn- sse-subscribe!
|
||||
"Add an http-kit async channel to the SSE listener set for `group`."
|
||||
[group ch]
|
||||
(swap! sse-clients update group (fnil conj #{}) ch)
|
||||
(http/on-close ch
|
||||
(fn [_status]
|
||||
(swap! sse-clients update group disj ch))))
|
||||
|
||||
(defn- sse-notify!
|
||||
"Send an SSE event to all listeners of the given groups."
|
||||
[groups]
|
||||
(doseq [group groups
|
||||
ch (get @sse-clients group)]
|
||||
(http/send! ch {:status 200
|
||||
:headers {"Content-Type" "text/event-stream"
|
||||
"Cache-Control" "no-cache"}
|
||||
:body (str "data: " group "\n\n")}
|
||||
false))) ;; false = don't close, keep streaming
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Handlers
|
||||
;; ---------------------------------------------------------------------------
|
||||
@@ -99,8 +126,39 @@
|
||||
:value (:value doc)
|
||||
:base-version (:base-version doc 0)})))
|
||||
docs)]
|
||||
;; Notify SSE listeners for affected groups
|
||||
(when (some #(= :ok (:status %)) results)
|
||||
(sse-notify! groups))
|
||||
(transit-response 200 results)))))
|
||||
|
||||
(defn- handle-events
|
||||
"GET /events?group=G — SSE endpoint. Holds connection open."
|
||||
[config req]
|
||||
(let [params (or (:query-params req) {})
|
||||
group (get params "group")
|
||||
user (authenticate config req)]
|
||||
(cond
|
||||
(not user)
|
||||
(transit-response 401 {:error "Unauthorized"})
|
||||
|
||||
(not group)
|
||||
(transit-response 400 {:error "Missing 'group' parameter"})
|
||||
|
||||
(not (authorized-group? user group))
|
||||
(transit-response 403 {:error "Access denied to group"})
|
||||
|
||||
:else
|
||||
(http/with-channel req ch
|
||||
(http/send! ch {:status 200
|
||||
:headers {"Content-Type" "text/event-stream"
|
||||
"Cache-Control" "no-cache"
|
||||
"Connection" "keep-alive"
|
||||
"Access-Control-Allow-Origin" "*"
|
||||
"X-Accel-Buffering" "no"}
|
||||
:body "data: connected\n\n"}
|
||||
false)
|
||||
(sse-subscribe! group ch)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Ring handler
|
||||
;; ---------------------------------------------------------------------------
|
||||
@@ -164,6 +222,10 @@
|
||||
(= :options (:request-method req))
|
||||
{:status 204 :headers {} :body nil}
|
||||
|
||||
;; SSE live events
|
||||
(= "/events" (:uri req))
|
||||
(handle-events config req)
|
||||
|
||||
;; Sync endpoints
|
||||
(= "/sync" (:uri req))
|
||||
(let [user (authenticate config req)]
|
||||
|
||||
Reference in New Issue
Block a user