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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user