feat(realtime): long polling for app-wide events behind proxies

When a proxy lets neither WebSocket nor SSE through, the app opens a
poll connection: the server buffers its events (an SSEClient without a
stream), each GET waits up to 25 s, and room joins work like for SSE.
Tasks and lists now update live there too, and note sync takes its
notifications from it instead of a second long poll. Idle connections
close after two minutes. The profile shows such a connection as limited.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-02 11:50:27 +01:00
1 parent c19d8fd03e
commit 2b7356d043
16 files changed
+369 -20

No files matched your search

+28
View File
@@ -3,7 +3,9 @@ package api
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/auth"
@@ -125,3 +127,29 @@ func (s *Server) handleSSERooms(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
}
// handleOpenPoll starts a long-poll connection (third transport after WebSocket and SSE, for proxies that
// let neither through). Rooms can be given right away or joined later via /events/rooms with the id.
func (s *Server) handleOpenPoll(w http.ResponseWriter, r *http.Request) {
var req struct {
Rooms []string `json:"rooms"`
}
_ = json.NewDecoder(http.MaxBytesReader(w, r.Body, 16384)).Decode(&req)
writeJSON(w, http.StatusOK, map[string]string{"pollId": s.wsHub.OpenPoll(auth.GetUserID(r.Context()), req.Rooms)})
}
// handlePoll returns the events of a poll connection as soon as there are some, at the latest after
// `wait` seconds (empty list). 404: the connection expired, the client opens a new one.
func (s *Server) handlePoll(w http.ResponseWriter, r *http.Request) {
wait := longPollMax
if sec, err := strconv.Atoi(r.URL.Query().Get("wait")); err == nil && sec >= 0 && time.Duration(sec)*time.Second < wait {
wait = time.Duration(sec) * time.Second
}
_ = http.NewResponseController(w).SetWriteDeadline(time.Now().Add(wait + 10*time.Second))
events, ok := s.wsHub.Poll(r.Context(), chi.URLParam(r, "id"), auth.GetUserID(r.Context()), wait)
if !ok {
writeError(w, http.StatusNotFound, "Verbindung abgelaufen")
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"events": events})
}
+60
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
@@ -354,3 +355,62 @@ func TestLogoutEndsLiveStreams(t *testing.T) {
}
}
}
// Long polling as the third transport: events of the user and of joined rooms arrive with the next
// poll, a poll waits until something happens, and nobody else can read the connection.
func TestLongPollDeliversEvents(t *testing.T) {
srv, store, ts, owner, token := startRealtimeServer(t)
_, strangerToken := newTestUser(t, store, "stranger@example.com")
resp := doJSON(t, ts, http.MethodPost, "/api/v1/events/poll", token, map[string]interface{}{})
var opened struct {
PollID string `json:"pollId"`
}
_ = json.NewDecoder(resp.Body).Decode(&opened)
if resp.StatusCode != http.StatusOK || opened.PollID == "" {
t.Fatalf("open poll: %d %+v", resp.StatusCode, opened)
}
poll := func(tok string, wait int) (int, []sseMsg) {
resp := doJSON(t, ts, http.MethodGet, "/api/v1/events/poll/"+opened.PollID+"?wait="+strconv.Itoa(wait), tok, nil)
var out struct {
Events []sseMsg `json:"events"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, out.Events
}
// Nothing happens: the poll answers empty after the wait
if code, events := poll(token, 0); code != http.StatusOK || len(events) != 0 {
t.Fatalf("empty poll: %d %v", code, events)
}
// A poll that is waiting returns as soon as an event comes
go func() {
time.Sleep(200 * time.Millisecond)
srv.wsHub.BroadcastToUser(owner.ID, "TASK_UPDATED", map[string]string{"id": "t1"})
}()
start := time.Now()
code, events := poll(token, 5)
if code != http.StatusOK || len(events) != 1 || events[0].Event != "TASK_UPDATED" || time.Since(start) > 3*time.Second {
t.Fatalf("waiting poll: %d %v after %s", code, events, time.Since(start))
}
// List rooms are joined like with SSE; events between polls wait in the buffer
list, _ := store.CreateList(owner.ID, "Einkauf", "🛒", "shopping")
if r := doJSON(t, ts, http.MethodPost, "/api/v1/events/rooms", token, map[string]string{"sseId": opened.PollID, "room": list.ID}); r.StatusCode != http.StatusOK {
t.Fatalf("join list room: %d", r.StatusCode)
}
srv.wsHub.BroadcastListEvent(list.ID, "ITEM_ADDED", map[string]string{"id": "i1"})
srv.wsHub.BroadcastListEvent(list.ID, "ITEM_UPDATED", map[string]string{"id": "i1"})
if code, events = poll(token, 5); code != http.StatusOK || len(events) != 2 || events[1].Event != "ITEM_UPDATED" {
t.Fatalf("list events: %d %v", code, events)
}
if code, _ = poll(strangerToken, 0); code != http.StatusNotFound {
t.Errorf("someone else's poll connection: want 404, got %d", code)
}
resp = doJSON(t, ts, http.MethodGet, "/api/v1/events/poll/poll_unknown?wait=0", token, nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("unknown poll connection: want 404, got %d", resp.StatusCode)
}
}
+2
View File
@@ -164,6 +164,8 @@ func (s *Server) Router() http.Handler {
r.Get("/api/v1/auth/me", s.handleMe)
r.Post("/api/v1/events/rooms", s.handleSSERooms)
r.Post("/api/v1/events/poll", s.handleOpenPoll)
r.Get("/api/v1/events/poll/{id}", s.handlePoll)
r.Post("/api/v1/auth/stream-ticket", s.handleStreamTicket)
r.Post("/api/v1/voice/transcribe", s.handleVoiceTranscribe)
r.Post("/api/v1/documents/{id}/media", s.handleUploadMedia)
+108
View File
@@ -1,6 +1,7 @@
package ws
import (
"context"
"encoding/json"
"fmt"
"log/slog"
@@ -104,6 +105,7 @@ type Hub struct {
sseClients map[*SSEClient]bool
sseUserRooms map[string]map[*SSEClient]bool
sseRooms map[string]map[*SSEClient]bool
polls map[string]*pollConn
closed bool
// authorizeRoom decides whether a user may receive events of a room; without it every join is denied
@@ -136,6 +138,112 @@ func NewHub() *Hub {
sseClients: make(map[*SSEClient]bool),
sseUserRooms: make(map[string]map[*SSEClient]bool),
sseRooms: make(map[string]map[*SSEClient]bool),
polls: make(map[string]*pollConn),
}
}
// Long polling, for networks that let neither WebSocket nor event streams through (proxies that buffer
// SSE): a poll connection is an SSEClient without a stream. Its events wait in the send buffer until the
// next poll picks them up; room joins work like for SSE (JoinSSERoomByID with its id).
// PollIdleTimeout: a poll connection nobody asked for this long is closed (tab closed, device asleep).
const PollIdleTimeout = 2 * time.Minute
type pollConn struct {
client *SSEClient
lastPoll time.Time
waiting int // polls running right now
}
// OpenPoll registers a poll connection for the user and returns its id.
func (h *Hub) OpenPoll(userID string, rooms []string) string {
h.sweepPolls()
c := &SSEClient{
id: domain.NewID("poll"),
userID: userID,
send: make(chan []byte, 256),
subscribedRooms: make(map[string]bool),
}
for _, room := range rooms {
if room = strings.TrimSpace(room); room != "" {
c.subscribedRooms[room] = true
}
}
h.RegisterSSE(c)
h.mu.Lock()
h.polls[c.id] = &pollConn{client: c, lastPoll: time.Now()}
h.mu.Unlock()
return c.id
}
// Poll waits up to wait for events of the user's poll connection id and returns everything buffered.
// ok is false when the connection is unknown or closed (expired, server restart): open a new one.
func (h *Hub) Poll(ctx context.Context, id, userID string, wait time.Duration) (events []json.RawMessage, ok bool) {
h.sweepPolls()
h.mu.Lock()
p := h.polls[id]
if p == nil || p.client.userID != userID {
h.mu.Unlock()
return nil, false
}
p.waiting++
h.mu.Unlock()
defer func() {
h.mu.Lock()
p.waiting--
p.lastPoll = time.Now()
h.mu.Unlock()
}()
// What is already waiting goes out right away; otherwise wait for the first event
events, open := drainBuffered(p.client.send)
if len(events) > 0 || !open {
return events, open || len(events) > 0
}
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case msg, open := <-p.client.send:
if !open {
return nil, false
}
rest, _ := drainBuffered(p.client.send) // whatever came along goes in the same answer
return append([]json.RawMessage{msg}, rest...), true
case <-timer.C:
case <-ctx.Done():
}
return events, true
}
// drainBuffered takes everything waiting in ch without blocking; open is false once ch is closed.
func drainBuffered(ch chan []byte) (events []json.RawMessage, open bool) {
events = []json.RawMessage{}
for {
select {
case msg, ok := <-ch:
if !ok {
return events, false
}
events = append(events, msg)
default:
return events, true
}
}
}
// sweepPolls closes poll connections that were not asked for within PollIdleTimeout.
func (h *Hub) sweepPolls() {
h.mu.Lock()
var stale []*SSEClient
for id, p := range h.polls {
if p.waiting == 0 && time.Since(p.lastPoll) > PollIdleTimeout {
stale = append(stale, p.client)
delete(h.polls, id)
}
}
h.mu.Unlock()
for _, c := range stale {
h.UnregisterSSE(c)
}
}
+27
View File
@@ -1,6 +1,7 @@
package ws
import (
"context"
"sync"
"testing"
"time"
@@ -90,3 +91,29 @@ func TestRoomJoinsRequireAuthorization(t *testing.T) {
t.Fatal("joining with another user's sseId must fail")
}
}
// Poll connections nobody asks for any more (tab closed) are closed and stop receiving events.
func TestIdlePollConnectionsAreClosed(t *testing.T) {
hub := NewHub()
id := hub.OpenPoll("user-1", nil)
hub.BroadcastToUser("user-1", "TEST_EVENT", nil)
if events, ok := hub.Poll(context.Background(), id, "user-1", 0); !ok || len(events) != 1 {
t.Fatalf("fresh poll connection: ok=%v events=%d", ok, len(events))
}
if _, ok := hub.Poll(context.Background(), id, "user-2", 0); ok {
t.Fatal("another user must not read the connection")
}
hub.mu.Lock()
hub.polls[id].lastPoll = time.Now().Add(-PollIdleTimeout - time.Second)
hub.mu.Unlock()
hub.OpenPoll("user-1", nil) // any poll activity sweeps
if _, ok := hub.Poll(context.Background(), id, "user-1", 0); ok {
t.Fatal("idle poll connection should be gone")
}
hub.mu.RLock()
defer hub.mu.RUnlock()
if len(hub.sseUserRooms["user-1"]) != 1 {
t.Fatalf("only the new connection should stay registered, got %d", len(hub.sseUserRooms["user-1"]))
}
}