Live UI updates via SSE: /api/v1/events nudge stream (first consumer: Federated requests) #476
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Cordy/Cairn#476
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Dogfood finding: an inbound OCM share does not appear in Federated → Requests until a page refresh. The bell self-heals within ~1–2 min (60s
pollBell+ 45s server notification cache), but the open view never re-fetches — office users read that as "broken". Decision (Nikola, 2026-09-10): build real push via SSE, not a poll piggyback.Why SSE, not WebSocket
Traffic is strictly server→client ("something changed"), so
EventSourcefits exactly: plain HTTP (no upgrade handling at Traefik), automatic reconnection with backoff built into the browser, no client library, no new dependency server-side. WebSocket buys bidirectionality we do not need and costs upgrade/proxy complexity.Design: nudges, not payloads
The stream never carries data — only topic nudges:
event: change/data: {"topic":"ocm"}. The client reacts by re-fetching the existing REST endpoints it already trusts. This keeps the derived-view architecture (the bell derives, nothing is stored) and means the stream needs no serialisation, no i18n, and no per-item authorisation of its own — the REST layer keeps that job. Nudges are idempotent, so dropping one under pressure is harmless (the poll fallback catches it).Topics for v1:
ocm(inbound share arrived / decided elsewhere) andbell(any notification-relevant change). Transfers, locks, licence can join later without protocol changes.Backend
internal/events:HubwithSubscribe(user) (<-chan string, cancel func())andPublish(user, topic string). Per-user fanout map + mutex; small buffered channels; drop-on-full (never block a mutation path on a slow browser); per-user connection cap (~4) to bound resources.GET /api/v1/events(session-authenticated, registered like the other user APIs): headersContent-Type: text/event-stream,Cache-Control: no-cache,X-Accel-Buffering: no(harmless on Traefik, saves nginx users); cast tohttp.Flusher, flush after every frame; heartbeat comment line every ~25s so proxy idle timeouts never cut a quiet stream; exit onr.Context().Done()and always run cancel().cairnd'shttp.ServersetsWriteTimeout, a long-lived stream dies at that deadline. Usehttp.ResponseController.SetWriteDeadlineto extend per heartbeat (or confirm WriteTimeout is unset). Check this FIRST — it is the classic silent SSE killer.shareshandler afterAddShareIn(nudge the recipient, topicsocm+bell);ocmShareInDecide(same user — another tab/device decided); peering inbox delivery; notifications-relevant licence transitions. Start with the OCM two; wire the rest as one-liners later.cairn_sse_connections; connect/disconnect at debug level.Frontend
new EventSource("/api/v1/events"); onchangeevents parse the topic —ocm: runpollBell()and, if the Federated view is the one on screen,fedReload();bell:pollBell().pollBellinterval as the fallback (SSE connected → it is just cheap belt-and-braces; consider stretching to 5 min while the stream is healthy).EventSourcereconnects automatically; after a redeploy (in-memory sessions) reconnection gets 401 → existing session-expiry handling takes over, polling still covers the gap. No custom reconnect code.EventSourceworks fine on plain http, unlike some clipboard APIs — no dogfood caveat.Testing (house TDD)
event:/data:frame; heartbeat appears; client disconnect frees the subscription (assert via hub count)./ocm/shares→ subscribed recipient stream receives theocmnudge.Out of scope v1
Last-Event-IDreplay — nudges make replay pointless (a reconnect just re-fetches).Recreate, so an in-process hub is correct today. The state-in-backend future (#138) with multiple replicas would need a shared pub/sub — record the constraint, do not build it.Docs
Handbook: short "live updates" note — proxy buffering (nginx
X-Accel-Buffering), heartbeat interval, and that polling remains the fallback so SSE is an enhancement, never a requirement.Shipped in v0.6.157, live on both dogfoods (
cairn_build_info{version="v0.6.157"}verified;/api/v1/eventsanswers 401 unauthenticated through the ingress, so the route is registered and reachable).Built exactly per the brief, house TDD (stubs first, red witnessed on the runner, then implementation):
internal/events.Hub(fanout, drop-on-full, per-user cap 4) + session-authenticated SSE atGET /api/v1/events(nudge frames only, 25s heartbeats,X-Accel-Buffering: no), publish on inbound share arrival and on decide/dismiss,startEvents()in the frontend with the 60s poll kept as fallback. PR #477. The WriteTimeout trap flagged in this issue turned out moot — the main server deliberately sets none (its own comment says so, for large transfers).Deferred, recorded on the PR:
cairn_sse_connectionsgauge, additional publishers (peering inbox, licence transitions), ocm-package integration test for the inbound nudge.Dogfood verification (needs a signed-in session, so over to Nikola): fresh login on files-bao, open Federated → Requests, then create an OCM share from Nextcloud towards nikola-test — the request card and the bell dot should appear within a second or two, no refresh. A second browser/tab on the Shares view should also update when you accept in the first.