Mobile notifications for iOS (#210)
* mobile: wire notification registration and listener
* implement backend components for push notifications
* refactor: agentic comment cleanup
* docs: use proper module name for particle processor
* set required env variables for push notifications
* bump version
* fix: always upsert push token on mobile start
* Revert "fix: always upsert push token on mobile start"
This reverts commit 90ff18a788.
* send push notifications regardless of online status
This commit was merged in pull request #210.
This commit is contained in:
@@ -10,20 +10,19 @@ import (
|
||||
|
||||
var ErrUnauthorized = errors.New("unauthorized")
|
||||
|
||||
// Authorizer validates whether a user can access a given channel.
|
||||
type Authorizer struct {
|
||||
networkReader network.Reader
|
||||
}
|
||||
|
||||
// NewAuthorizer creates a new channel authorizer.
|
||||
func NewAuthorizer(networkReader network.Reader) *Authorizer {
|
||||
return &Authorizer{networkReader: networkReader}
|
||||
}
|
||||
|
||||
// Authorize checks if the given humanID is allowed to subscribe to the channel.
|
||||
// Channel formats:
|
||||
// - network:{networkId}
|
||||
// - stream:{networkId}:{streamId}
|
||||
// Authorize accepts channel IDs of the form:
|
||||
//
|
||||
// network:{networkId}
|
||||
// stream:{networkId}:{streamId}
|
||||
// _presence:{humanId}
|
||||
func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) error {
|
||||
parts := strings.SplitN(channelID, ":", 2)
|
||||
if len(parts) < 2 {
|
||||
@@ -39,8 +38,7 @@ func (a *Authorizer) Authorize(ctx context.Context, channelID, humanID string) e
|
||||
case "stream":
|
||||
return a.authorizeStream(ctx, rest, humanID)
|
||||
case "_presence":
|
||||
// Always allowed — used for global online presence tracking.
|
||||
// The channel ID is _presence:{humanId}, so verify the humanId matches.
|
||||
// Only the owning human may subscribe to their presence channel.
|
||||
if rest != humanID {
|
||||
return ErrUnauthorized
|
||||
}
|
||||
@@ -61,8 +59,7 @@ func (a *Authorizer) authorizeNetwork(ctx context.Context, networkID, humanID st
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorizeStream expects rest to be "{networkId}:{streamId}".
|
||||
// We only check network membership — stream visibility is handled by network access.
|
||||
// rest is "{networkId}:{streamId}"; stream-level visibility is enforced by network access.
|
||||
func (a *Authorizer) authorizeStream(ctx context.Context, rest, humanID string) error {
|
||||
parts := strings.SplitN(rest, ":", 2)
|
||||
if len(parts) < 2 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package pusher
|
||||
|
||||
// Channel tracks the local connections subscribed to a channel on this pod.
|
||||
// All methods are only called from the Hub goroutine — no locks needed.
|
||||
// Channel tracks the local connections subscribed on this pod.
|
||||
// State is only mutated by the Hub goroutine, so no locks are needed.
|
||||
type Channel struct {
|
||||
id string
|
||||
members map[*Conn]string // conn → humanID
|
||||
@@ -26,7 +26,7 @@ func (ch *Channel) isEmpty() bool {
|
||||
return len(ch.members) == 0
|
||||
}
|
||||
|
||||
// localHumanIDs returns the deduplicated set of humanIDs connected on this pod.
|
||||
// Deduplicated set; the same human may have multiple connections.
|
||||
func (ch *Channel) localHumanIDs() []string {
|
||||
seen := make(map[string]bool, len(ch.members))
|
||||
ids := make([]string, 0, len(ch.members))
|
||||
@@ -39,7 +39,6 @@ func (ch *Channel) localHumanIDs() []string {
|
||||
return ids
|
||||
}
|
||||
|
||||
// hasHumanID returns true if the given humanID has at least one local connection.
|
||||
func (ch *Channel) hasHumanID(humanID string) bool {
|
||||
for _, hid := range ch.members {
|
||||
if hid == humanID {
|
||||
@@ -49,7 +48,6 @@ func (ch *Channel) hasHumanID(humanID string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// broadcast sends a message to all local connections except the excluded one.
|
||||
func (ch *Channel) broadcast(msg ServerMessage, exclude *Conn) {
|
||||
for conn := range ch.members {
|
||||
if conn != exclude {
|
||||
|
||||
@@ -11,13 +11,12 @@ import (
|
||||
|
||||
const sendBufferSize = 256
|
||||
|
||||
// Conn wraps a WebSocket connection with identity and a send buffer.
|
||||
type Conn struct {
|
||||
id string
|
||||
humanID string
|
||||
ws *websocket.Conn
|
||||
send chan []byte
|
||||
once sync.Once // ensures close logic runs once
|
||||
once sync.Once // guards Close
|
||||
}
|
||||
|
||||
func newConn(id, humanID string, ws *websocket.Conn) *Conn {
|
||||
@@ -29,8 +28,8 @@ func newConn(id, humanID string, ws *websocket.Conn) *Conn {
|
||||
}
|
||||
}
|
||||
|
||||
// ReadPump reads messages from the WebSocket and forwards them to the hub.
|
||||
// It blocks until the connection is closed or the context is cancelled.
|
||||
// ReadPump forwards inbound frames to the hub; blocks until the connection
|
||||
// closes or ctx is cancelled.
|
||||
func (c *Conn) ReadPump(ctx context.Context, hub *Hub) {
|
||||
defer hub.disconnect(c)
|
||||
|
||||
@@ -45,7 +44,6 @@ func (c *Conn) ReadPump(ctx context.Context, hub *Hub) {
|
||||
return
|
||||
}
|
||||
|
||||
// Respond to keep-alive pings
|
||||
if string(data) == "ping" {
|
||||
if err := c.ws.Write(ctx, websocket.MessageText, []byte("pong")); err != nil {
|
||||
slog.Warn("websocket pong write error", "connId", c.id, "error", err)
|
||||
@@ -84,7 +82,6 @@ func (c *Conn) ReadPump(ctx context.Context, hub *Hub) {
|
||||
}
|
||||
}
|
||||
|
||||
// WritePump drains the send buffer and writes messages to the WebSocket.
|
||||
func (c *Conn) WritePump(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
@@ -102,8 +99,7 @@ func (c *Conn) WritePump(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Send enqueues a ServerMessage to be written to the WebSocket.
|
||||
// If the send buffer is full, the connection is closed (slow client).
|
||||
// A full send buffer closes the connection (slow client policy).
|
||||
func (c *Conn) Send(msg ServerMessage) {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
@@ -119,7 +115,6 @@ func (c *Conn) Send(msg ServerMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
// Close closes the WebSocket connection and the send channel.
|
||||
func (c *Conn) Close() {
|
||||
c.once.Do(func() {
|
||||
c.ws.Close(websocket.StatusNormalClosure, "closing")
|
||||
|
||||
@@ -43,7 +43,6 @@ type Hub struct {
|
||||
remoteEventCh chan *remoteEvent
|
||||
}
|
||||
|
||||
// NewHub creates a new Hub.
|
||||
func NewHub(bridge *RedisBridge, authorizer *Authorizer) *Hub {
|
||||
return &Hub{
|
||||
channels: make(map[string]*Channel),
|
||||
@@ -84,7 +83,6 @@ func (h *Hub) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) {
|
||||
// Authorize channel access
|
||||
if err := h.authorizer.Authorize(ctx, req.channelID, req.conn.humanID); err != nil {
|
||||
req.conn.Send(ServerMessage{
|
||||
Type: TypeError,
|
||||
@@ -94,7 +92,6 @@ func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create local channel
|
||||
ch, ok := h.channels[req.channelID]
|
||||
if !ok {
|
||||
ch = newChannel(req.channelID)
|
||||
@@ -104,32 +101,27 @@ func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) {
|
||||
// Capture before addMember so multi-tab joins don't emit a spurious join.
|
||||
wasPresentLocally := ch.hasHumanID(req.conn.humanID)
|
||||
|
||||
// Add to local channel
|
||||
ch.addMember(req.conn, req.conn.humanID)
|
||||
|
||||
// Track in reverse index
|
||||
if h.connChannels[req.conn] == nil {
|
||||
h.connChannels[req.conn] = make(map[string]bool)
|
||||
}
|
||||
h.connChannels[req.conn][req.channelID] = true
|
||||
|
||||
// Register in Redis and get global presence
|
||||
presence, err := h.bridge.Subscribe(ctx, req.channelID, req.conn.id, req.conn.humanID)
|
||||
if err != nil {
|
||||
slog.Error("redis subscribe failed", "channelId", req.channelID, "error", err)
|
||||
// Still send local presence as fallback
|
||||
// Fall back to local-only presence.
|
||||
presence = ch.localHumanIDs()
|
||||
}
|
||||
|
||||
// Send subscribed ack with presence snapshot
|
||||
req.conn.Send(ServerMessage{
|
||||
Type: TypeSubscribed,
|
||||
Channel: req.channelID,
|
||||
Presence: presence,
|
||||
})
|
||||
|
||||
// Notify other local members. The Redis self-filter drops our own echo,
|
||||
// so same-pod peers would otherwise never hear about this join.
|
||||
// Redis self-filter drops our own echo, so same-pod peers need a direct nudge.
|
||||
if !wasPresentLocally {
|
||||
ch.broadcast(ServerMessage{
|
||||
Type: TypeJoin,
|
||||
@@ -147,18 +139,15 @@ func (h *Hub) handleUnsubscribe(ctx context.Context, req *unsubscribeRequest) {
|
||||
|
||||
ch.removeMember(req.conn)
|
||||
|
||||
// Remove from reverse index
|
||||
if chans, ok := h.connChannels[req.conn]; ok {
|
||||
delete(chans, req.channelID)
|
||||
}
|
||||
|
||||
// Update Redis
|
||||
if err := h.bridge.Unsubscribe(ctx, req.channelID, req.conn.id, req.conn.humanID); err != nil {
|
||||
slog.Error("redis unsubscribe failed", "channelId", req.channelID, "error", err)
|
||||
}
|
||||
|
||||
// Notify other local members iff the humanID is fully gone from this pod
|
||||
// (multi-tab: other conns keep them present, so no leave fires).
|
||||
// Only emit leave once the humanID has no remaining tabs on this pod.
|
||||
if !ch.hasHumanID(req.conn.humanID) {
|
||||
ch.broadcast(ServerMessage{
|
||||
Type: TypeLeave,
|
||||
@@ -167,7 +156,6 @@ func (h *Hub) handleUnsubscribe(ctx context.Context, req *unsubscribeRequest) {
|
||||
}, req.conn)
|
||||
}
|
||||
|
||||
// Clean up empty local channel
|
||||
if ch.isEmpty() {
|
||||
delete(h.channels, req.channelID)
|
||||
}
|
||||
@@ -179,13 +167,12 @@ func (h *Hub) handleBroadcast(ctx context.Context, req *broadcastRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check that the sender is actually in the channel
|
||||
if _, isMember := ch.members[req.conn]; !isMember {
|
||||
req.conn.sendError("not subscribed to channel: " + req.channelID)
|
||||
return
|
||||
}
|
||||
|
||||
// Deliver to local connections (except sender)
|
||||
// Local fanout (excluding sender), then publish for other pods.
|
||||
ch.broadcast(ServerMessage{
|
||||
Type: TypeMessage,
|
||||
Channel: req.channelID,
|
||||
@@ -193,7 +180,6 @@ func (h *Hub) handleBroadcast(ctx context.Context, req *broadcastRequest) {
|
||||
Payload: req.payload,
|
||||
}, req.conn)
|
||||
|
||||
// Publish to Redis for other pods
|
||||
h.bridge.Broadcast(ctx, req.channelID, req.conn.humanID, req.payload)
|
||||
}
|
||||
|
||||
@@ -234,7 +220,8 @@ func (h *Hub) handleDisconnect(ctx context.Context, conn *Conn) {
|
||||
func (h *Hub) handleRemoteEvent(evt *remoteEvent) {
|
||||
ch, ok := h.channels[evt.channelID]
|
||||
if !ok {
|
||||
return // no local connections care about this channel
|
||||
// No local subscribers — drop the event.
|
||||
return
|
||||
}
|
||||
|
||||
switch evt.event.Type {
|
||||
@@ -262,12 +249,10 @@ func (h *Hub) handleRemoteEvent(evt *remoteEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe enqueues a subscribe request for the given connection and channel.
|
||||
func (h *Hub) Subscribe(conn *Conn, channelID string) {
|
||||
h.subscribeCh <- &subscribeRequest{conn: conn, channelID: channelID}
|
||||
}
|
||||
|
||||
// disconnect sends a connection to the disconnect channel.
|
||||
func (h *Hub) disconnect(conn *Conn) {
|
||||
h.disconnectCh <- conn
|
||||
}
|
||||
|
||||
@@ -23,22 +23,22 @@ const (
|
||||
pubsubPrefix = "pusher:events:"
|
||||
)
|
||||
|
||||
// redisEvent is published/received via Redis Pub/Sub for cross-pod communication.
|
||||
// Wire format for cross-pod Pub/Sub.
|
||||
type redisEvent struct {
|
||||
Type string `json:"type"` // "join", "leave", "message"
|
||||
HumanID string `json:"humanId,omitempty"` // who triggered the event
|
||||
PodID string `json:"podId,omitempty"` // originating pod
|
||||
Payload json.RawMessage `json:"payload,omitempty"` // for message events
|
||||
Payload json.RawMessage `json:"payload,omitempty"` // message events only
|
||||
}
|
||||
|
||||
// RedisBridge handles cross-pod coordination via Redis Pub/Sub and presence tracking.
|
||||
// RedisBridge handles cross-pod coordination via Redis Pub/Sub and presence
|
||||
// tracking.
|
||||
type RedisBridge struct {
|
||||
client *redis.Client
|
||||
podID string
|
||||
hub *Hub // set after hub is created
|
||||
hub *Hub // wired post-construction; see SetHub
|
||||
}
|
||||
|
||||
// NewRedisBridge creates a new Redis bridge for cross-pod coordination.
|
||||
func NewRedisBridge(client *redis.Client, podID string) *RedisBridge {
|
||||
return &RedisBridge{
|
||||
client: client,
|
||||
@@ -46,20 +46,20 @@ func NewRedisBridge(client *redis.Client, podID string) *RedisBridge {
|
||||
}
|
||||
}
|
||||
|
||||
// SetHub sets the hub reference. Called during initialization.
|
||||
// SetHub resolves the circular dependency between Hub and RedisBridge.
|
||||
func (rb *RedisBridge) SetHub(hub *Hub) {
|
||||
rb.hub = hub
|
||||
}
|
||||
|
||||
// --- Presence management (called by hub goroutine) ---
|
||||
// --- Presence management ---
|
||||
|
||||
// Subscribe adds a connection to a channel in Redis.
|
||||
// Returns the current presence set for the channel.
|
||||
// Subscribe records the connection in Redis and returns the channel's
|
||||
// current deduplicated presence set.
|
||||
func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID string) ([]string, error) {
|
||||
key := channelConnsKey(channelID)
|
||||
field := rb.connField(connID)
|
||||
|
||||
// Check if humanID was already present before adding
|
||||
// Snapshot before the insert so multi-tab joins don't double-emit.
|
||||
existingMembers, err := rb.client.HVals(ctx, key).Result()
|
||||
if err != nil && err != redis.Nil {
|
||||
return nil, fmt.Errorf("failed to get channel members: %w", err)
|
||||
@@ -67,12 +67,10 @@ func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID
|
||||
|
||||
wasPresent := containsString(existingMembers, humanID)
|
||||
|
||||
// Add this connection
|
||||
if err := rb.client.HSet(ctx, key, field, humanID).Err(); err != nil {
|
||||
return nil, fmt.Errorf("failed to add connection to channel: %w", err)
|
||||
}
|
||||
|
||||
// Publish join event if this is a new humanID in the channel
|
||||
if !wasPresent {
|
||||
rb.publishEvent(ctx, channelID, redisEvent{
|
||||
Type: TypeJoin,
|
||||
@@ -81,7 +79,6 @@ func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID
|
||||
})
|
||||
}
|
||||
|
||||
// Return deduplicated presence set
|
||||
allMembers, err := rb.client.HVals(ctx, key).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get channel members: %w", err)
|
||||
@@ -89,7 +86,6 @@ func (rb *RedisBridge) Subscribe(ctx context.Context, channelID, connID, humanID
|
||||
return deduplicateStrings(allMembers), nil
|
||||
}
|
||||
|
||||
// Unsubscribe removes a connection from a channel in Redis.
|
||||
func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, humanID string) error {
|
||||
key := channelConnsKey(channelID)
|
||||
field := rb.connField(connID)
|
||||
@@ -98,7 +94,7 @@ func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, human
|
||||
return fmt.Errorf("failed to remove connection from channel: %w", err)
|
||||
}
|
||||
|
||||
// Check if this humanID is still present via other connections
|
||||
// Only emit leave once this humanID has no tabs left in the channel.
|
||||
remainingMembers, err := rb.client.HVals(ctx, key).Result()
|
||||
if err != nil && err != redis.Nil {
|
||||
return fmt.Errorf("failed to get remaining members: %w", err)
|
||||
@@ -112,7 +108,6 @@ func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, human
|
||||
})
|
||||
}
|
||||
|
||||
// Clean up empty channel hash
|
||||
if len(remainingMembers) == 0 {
|
||||
rb.client.Del(ctx, key)
|
||||
}
|
||||
@@ -120,7 +115,6 @@ func (rb *RedisBridge) Unsubscribe(ctx context.Context, channelID, connID, human
|
||||
return nil
|
||||
}
|
||||
|
||||
// Broadcast publishes a message event to all pods.
|
||||
func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string, payload json.RawMessage) {
|
||||
rb.publishEvent(ctx, channelID, redisEvent{
|
||||
Type: TypeMessage,
|
||||
@@ -130,7 +124,6 @@ func (rb *RedisBridge) Broadcast(ctx context.Context, channelID, humanID string,
|
||||
})
|
||||
}
|
||||
|
||||
// GetPresence returns the deduplicated humanIDs for the given channels.
|
||||
func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (map[string][]string, error) {
|
||||
result := make(map[string][]string, len(channelIDs))
|
||||
for _, chID := range channelIDs {
|
||||
@@ -143,8 +136,7 @@ func (rb *RedisBridge) GetPresence(ctx context.Context, channelIDs []string) (ma
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAllConnectedHumanIDs scans all channel connection hashes in Redis and returns
|
||||
// the deduplicated set of all humanIDs that have at least one active connection.
|
||||
// Returns every humanID with at least one active connection cluster-wide.
|
||||
func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, error) {
|
||||
allHumanIDs := make(map[string]bool)
|
||||
var cursor uint64
|
||||
@@ -178,10 +170,9 @@ func (rb *RedisBridge) GetAllConnectedHumanIDs(ctx context.Context) ([]string, e
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- Pub/Sub listener (runs in its own goroutine) ---
|
||||
// --- Pub/Sub listener ---
|
||||
|
||||
// Listen subscribes to Redis Pub/Sub and forwards events to the local hub.
|
||||
// Blocks until the context is cancelled.
|
||||
// Listen forwards Redis Pub/Sub events to the local hub; blocks until ctx is cancelled.
|
||||
func (rb *RedisBridge) Listen(ctx context.Context) {
|
||||
pubsub := rb.client.PSubscribe(ctx, pubsubPrefix+"*")
|
||||
defer pubsub.Close()
|
||||
@@ -201,7 +192,7 @@ func (rb *RedisBridge) Listen(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
|
||||
// Extract channel ID from topic: "pusher:events:{channelID}"
|
||||
// Topic: "pusher:events:{channelID}".
|
||||
channelID := strings.TrimPrefix(msg.Channel, pubsubPrefix)
|
||||
if channelID == "" {
|
||||
return
|
||||
@@ -213,7 +204,7 @@ func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip events originating from this pod — the local hub already handled them
|
||||
// Same-pod events were already handled by the local hub.
|
||||
if event.PodID == rb.podID {
|
||||
return
|
||||
}
|
||||
@@ -222,20 +213,18 @@ func (rb *RedisBridge) handlePubSubMessage(msg *redis.Message) {
|
||||
return
|
||||
}
|
||||
|
||||
// Forward to local hub for delivery to local WebSocket connections
|
||||
rb.hub.remoteEventCh <- &remoteEvent{
|
||||
channelID: channelID,
|
||||
event: event,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Heartbeat + cleanup (runs in its own goroutine) ---
|
||||
// --- Heartbeat + cleanup ---
|
||||
|
||||
// Heartbeat maintains this pod's liveness key and cleans up stale pods.
|
||||
// Heartbeat refreshes this pod's liveness key and reaps stale pods on a tick.
|
||||
func (rb *RedisBridge) Heartbeat(ctx context.Context) {
|
||||
podKey := podKeyPrefix + rb.podID
|
||||
|
||||
// Initial heartbeat
|
||||
rb.client.Set(ctx, podKey, "alive", podHeartbeatTTL)
|
||||
|
||||
heartbeatTicker := time.NewTicker(podHeartbeatInterval)
|
||||
@@ -246,7 +235,7 @@ func (rb *RedisBridge) Heartbeat(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// On shutdown, remove our pod key and clean up our connections
|
||||
// On shutdown, drop our pod key and reclaim our connection slots.
|
||||
rb.client.Del(context.Background(), podKey)
|
||||
rb.cleanupPod(context.Background(), rb.podID)
|
||||
return
|
||||
@@ -259,7 +248,8 @@ func (rb *RedisBridge) Heartbeat(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
|
||||
// Scan all channel conn hashes for pod IDs, then check if those pods are still alive
|
||||
// Collect every pod referenced in channel-conn hashes, then drop those
|
||||
// whose liveness key has expired.
|
||||
var cursor uint64
|
||||
knownPods := make(map[string]bool)
|
||||
alivePods := make(map[string]bool)
|
||||
@@ -290,7 +280,6 @@ func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check which pods are still alive
|
||||
for podID := range knownPods {
|
||||
exists, err := rb.client.Exists(ctx, podKeyPrefix+podID).Result()
|
||||
if err != nil {
|
||||
@@ -301,7 +290,6 @@ func (rb *RedisBridge) cleanupStalePods(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up dead pods
|
||||
for podID := range knownPods {
|
||||
if !alivePods[podID] {
|
||||
slog.Info("cleaning up stale pod", "podId", podID)
|
||||
@@ -328,7 +316,6 @@ func (rb *RedisBridge) cleanupPod(ctx context.Context, podID string) {
|
||||
for field, humanID := range fields {
|
||||
if extractPodID(field) == podID {
|
||||
rb.client.HDel(ctx, key, field)
|
||||
// Check if this humanID is now gone from the channel
|
||||
remaining, _ := rb.client.HVals(ctx, key).Result()
|
||||
if !containsString(remaining, humanID) {
|
||||
rb.publishEvent(ctx, channelID, redisEvent{
|
||||
@@ -369,15 +356,15 @@ func channelConnsKey(channelID string) string {
|
||||
return channelConnsPrefix + channelID + channelConnsSuffix
|
||||
}
|
||||
|
||||
// "pusher:ch:{channelID}:conns" → channelID
|
||||
func extractChannelID(redisKey string) string {
|
||||
// "pusher:ch:{channelID}:conns" → channelID
|
||||
s := strings.TrimPrefix(redisKey, channelConnsPrefix)
|
||||
s = strings.TrimSuffix(s, channelConnsSuffix)
|
||||
return s
|
||||
}
|
||||
|
||||
// "{podID}:{connID}" → podID
|
||||
func extractPodID(field string) string {
|
||||
// "{podID}:{connID}" → podID
|
||||
parts := strings.SplitN(field, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[0]
|
||||
|
||||
@@ -15,14 +15,12 @@ import (
|
||||
type Server struct {
|
||||
pbpusher.UnimplementedPusherServiceServer
|
||||
|
||||
ctx context.Context // server-scoped context for graceful shutdown
|
||||
ctx context.Context // server-scoped; cancelling closes all WebSockets gracefully
|
||||
hub *Hub
|
||||
bridge *RedisBridge
|
||||
authSvc auth.SessionReader
|
||||
}
|
||||
|
||||
// NewServer creates a new pusher server. The ctx controls the lifetime of all
|
||||
// WebSocket connections — when cancelled, all connections are closed gracefully.
|
||||
func NewServer(ctx context.Context, hub *Hub, bridge *RedisBridge, authSvc auth.SessionReader) *Server {
|
||||
return &Server{
|
||||
ctx: ctx,
|
||||
@@ -32,9 +30,8 @@ func NewServer(ctx context.Context, hub *Hub, bridge *RedisBridge, authSvc auth.
|
||||
}
|
||||
}
|
||||
|
||||
// HandleWebSocket handles the WebSocket upgrade and connection lifecycle.
|
||||
func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
// Authenticate via query param (WebSocket upgrade can't use custom headers)
|
||||
// Token rides in the query string — WebSocket upgrades can't carry custom headers.
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
http.Error(w, "token required", http.StatusUnauthorized)
|
||||
@@ -47,9 +44,8 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Accept WebSocket upgrade
|
||||
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
// Allow all origins for now — CORS is handled at the gateway level
|
||||
// CORS is enforced at the gateway.
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -62,25 +58,21 @@ func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
slog.Info("websocket connected", "connId", connID, "humanId", session.HumanId)
|
||||
|
||||
// Use server context, NOT r.Context(). After WebSocket upgrade, the HTTP
|
||||
// request context can be cancelled by load balancers or Go's HTTP server,
|
||||
// and nhooyr/websocket permanently closes the conn on any context error.
|
||||
// Use the server context, not r.Context(): after upgrade the HTTP request
|
||||
// context can be cancelled by load balancers and nhooyr/websocket would
|
||||
// then permanently close the conn.
|
||||
ctx, cancel := context.WithCancel(s.ctx)
|
||||
defer cancel()
|
||||
|
||||
// Auto-subscribe to presence channel so this user appears online
|
||||
// Auto-subscribe to the presence channel so this user appears online.
|
||||
s.hub.Subscribe(conn, "_presence:"+session.HumanId)
|
||||
|
||||
// WritePump runs in a separate goroutine
|
||||
go conn.WritePump(ctx)
|
||||
|
||||
// ReadPump blocks until the connection closes
|
||||
conn.ReadPump(ctx, s.hub)
|
||||
|
||||
slog.Info("websocket disconnected", "connId", connID, "humanId", session.HumanId)
|
||||
}
|
||||
|
||||
// GetOnlineHumanIds returns all currently connected human IDs.
|
||||
func (s *Server) GetOnlineHumanIds(ctx context.Context, _ *pbpusher.GetOnlineHumanIdsRequest) (*pbpusher.GetOnlineHumanIdsResponse, error) {
|
||||
humanIDs, err := s.bridge.GetAllConnectedHumanIDs(ctx)
|
||||
if err != nil {
|
||||
@@ -89,7 +81,6 @@ func (s *Server) GetOnlineHumanIds(ctx context.Context, _ *pbpusher.GetOnlineHum
|
||||
return &pbpusher.GetOnlineHumanIdsResponse{HumanIds: humanIDs}, nil
|
||||
}
|
||||
|
||||
// IsOnline checks whether specific humans are currently online.
|
||||
func (s *Server) IsOnline(ctx context.Context, req *pbpusher.IsOnlineRequest) (*pbpusher.IsOnlineResponse, error) {
|
||||
allOnline, err := s.bridge.GetAllConnectedHumanIDs(ctx)
|
||||
if err != nil {
|
||||
@@ -106,7 +97,6 @@ func (s *Server) IsOnline(ctx context.Context, req *pbpusher.IsOnlineRequest) (*
|
||||
return &pbpusher.IsOnlineResponse{Online: result}, nil
|
||||
}
|
||||
|
||||
// GetChannelPresence returns presence (human IDs) for specific channels.
|
||||
func (s *Server) GetChannelPresence(ctx context.Context, req *pbpusher.GetChannelPresenceRequest) (*pbpusher.GetChannelPresenceResponse, error) {
|
||||
presence, err := s.bridge.GetPresence(ctx, req.ChannelIds)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,14 +18,12 @@ const (
|
||||
TypeError = "error"
|
||||
)
|
||||
|
||||
// ClientMessage is a message sent from a WebSocket client to the server.
|
||||
type ClientMessage struct {
|
||||
Type string `json:"type"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// ServerMessage is a message sent from the server to a WebSocket client.
|
||||
type ServerMessage struct {
|
||||
Type string `json:"type"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user