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:
Florian Schroedl
2026-04-04 17:14:55 +02:00
parent 570a087f53
commit 455e80f4e8
4 changed files with 144 additions and 13 deletions

View File

@@ -275,26 +275,31 @@
(defn- start-sync-loop!
"Start the background sync loop. Returns a stop function."
[sa]
(let [stop-ch (.-stop_ch sa)
interval (.-sync_interval sa)]
;; Periodic sync
(let [stop-ch (.-stop_ch sa)
interval (.-sync_interval sa)
cleanups (atom [])]
;; Periodic sync (fallback)
(go-loop []
(let [[_ ch] (alts! [stop-ch (timeout interval)])]
(when-not (= ch stop-ch)
(<! (do-sync! sa))
(recur))))
;; Online/offline handler
(let [cleanup (sync/on-connectivity-change
(fn [] ; online
(go (<! (do-sync! sa))))
(fn [] ; offline
nil))]
(reset! (.-cleanup_fn sa) cleanup))
(swap! cleanups conj
(sync/on-connectivity-change
(fn [] (go (<! (do-sync! sa))))
(fn [] nil)))
;; SSE — live pull on server push
(when-let [opts (.-server_opts sa)]
(swap! cleanups conj
(sync/listen-events opts (.-group sa)
(fn [_group]
(go (<! (do-pull! sa)))))))
(reset! (.-cleanup_fn sa) cleanups)
;; Return stop function
(fn []
(put! stop-ch :stop)
(when-let [cleanup @(.-cleanup_fn sa)]
(cleanup)))))
(doseq [f @(.-cleanup_fn sa)] (f)))))
;; ---------------------------------------------------------------------------
;; Public API
@@ -352,5 +357,4 @@
"Stop the sync loop and clean up. Does not close the IDB connection."
[sa]
(put! (.-stop_ch sa) :stop)
(when-let [cleanup @(.-cleanup_fn sa)]
(cleanup)))
(doseq [f @(.-cleanup_fn sa)] (f)))

View File

@@ -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)]

View File

@@ -1,6 +1,7 @@
(ns pocketbook.sync
"HTTP sync client — pull and push documents to/from the Pocketbook server."
(:require [cognitect.transit :as t]
[clojure.string :as str]
[cljs.core.async :as async :refer [chan put!]]))
;; ---------------------------------------------------------------------------
@@ -91,6 +92,33 @@
(async/close! ch)))
ch))
;; ---------------------------------------------------------------------------
;; SSE — live change notifications
;; ---------------------------------------------------------------------------
(defn listen-events
"Open an SSE connection to /events?group=G. Calls `on-change` when the
server signals new data. Returns a cleanup function."
[{:keys [server token]} group on-change]
(let [base-url (-> server
(str/replace #"/sync$" "")
(str/replace #"/$" ""))
url (str base-url "/events?group=" (js/encodeURIComponent group))
es (js/EventSource. url)]
(set! (.-onmessage es)
(fn [e]
(let [data (.-data e)]
;; Skip initial "connected" message
(when (not= data "connected")
(on-change data)))))
(set! (.-onerror es)
(fn [_e]
;; EventSource auto-reconnects; nothing to do
nil))
;; Return cleanup
(fn []
(.close es))))
;; ---------------------------------------------------------------------------
;; Online detection
;; ---------------------------------------------------------------------------