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:
Arjun Patel
2026-05-18 12:44:31 -07:00
committed by GitHub
parent a564ea819b
commit d262f734f0
61 changed files with 1682 additions and 531 deletions
+23 -36
View File
@@ -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]