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

@@ -140,3 +140,40 @@
(is (= uuid (:uuid pulled)))
(is (= inst (:inst pulled)))
(is (= {:a {:b 42}} (:nested pulled))))))
(deftest sse-notifies-on-push
(testing "SSE endpoint sends event when a push succeeds"
(let [;; Open SSE connection
sse-req (-> (HttpRequest/newBuilder)
(.uri (URI. (url "/events" "group=todo")))
(.GET)
(.build))
;; Use a short timeout — we'll read what we get
events (atom [])
future (java.util.concurrent.CompletableFuture/supplyAsync
(reify java.util.function.Supplier
(get [_]
(try
(let [resp (.send client sse-req
(HttpResponse$BodyHandlers/ofInputStream))
is (.body resp)
rdr (java.io.BufferedReader.
(java.io.InputStreamReader. is "UTF-8"))]
;; Read lines until we get a data event about "todo"
(loop [deadline (+ (System/currentTimeMillis) 3000)]
(when (< (System/currentTimeMillis) deadline)
(when-let [line (.readLine rdr)]
(swap! events conj line)
(if (= line "data: todo")
@events
(recur deadline))))))
(catch Exception _ @events)))))]
;; Give SSE time to connect
(Thread/sleep 200)
;; Push a doc — should trigger SSE event
(post-transit "/sync"
[{:id "todo:sse-test" :value {:text "SSE!"} :base-version 0}])
;; Wait for SSE reader to receive it
(let [result (.get future 4 java.util.concurrent.TimeUnit/SECONDS)]
(is (some #(= "data: todo" %) result)
"SSE should have received a 'data: todo' event")))))