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:
1 parent
c19d8fd03e
commit
2b7356d043
16 files changed
+369
-20
No files matched your search
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user