d262f734f0
* 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
259 lines
5.9 KiB
Go
259 lines
5.9 KiB
Go
package pusher
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
)
|
|
|
|
type subscribeRequest struct {
|
|
conn *Conn
|
|
channelID string
|
|
}
|
|
|
|
type unsubscribeRequest struct {
|
|
conn *Conn
|
|
channelID string
|
|
}
|
|
|
|
type broadcastRequest struct {
|
|
conn *Conn
|
|
channelID string
|
|
payload json.RawMessage
|
|
}
|
|
|
|
type remoteEvent struct {
|
|
channelID string
|
|
event redisEvent
|
|
}
|
|
|
|
// Hub manages all local WebSocket connections and channels on this pod.
|
|
// All state mutations happen in a single goroutine via Go channels — no locks.
|
|
type Hub struct {
|
|
channels map[string]*Channel
|
|
connChannels map[*Conn]map[string]bool // reverse index: conn → set of channel IDs
|
|
|
|
bridge *RedisBridge
|
|
authorizer *Authorizer
|
|
|
|
subscribeCh chan *subscribeRequest
|
|
unsubscribeCh chan *unsubscribeRequest
|
|
broadcastCh chan *broadcastRequest
|
|
disconnectCh chan *Conn
|
|
remoteEventCh chan *remoteEvent
|
|
}
|
|
|
|
func NewHub(bridge *RedisBridge, authorizer *Authorizer) *Hub {
|
|
return &Hub{
|
|
channels: make(map[string]*Channel),
|
|
connChannels: make(map[*Conn]map[string]bool),
|
|
bridge: bridge,
|
|
authorizer: authorizer,
|
|
subscribeCh: make(chan *subscribeRequest, 256),
|
|
unsubscribeCh: make(chan *unsubscribeRequest, 256),
|
|
broadcastCh: make(chan *broadcastRequest, 256),
|
|
disconnectCh: make(chan *Conn, 256),
|
|
remoteEventCh: make(chan *remoteEvent, 256),
|
|
}
|
|
}
|
|
|
|
// Run starts the hub event loop. Blocks until the context is cancelled.
|
|
func (h *Hub) Run(ctx context.Context) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
|
|
case req := <-h.subscribeCh:
|
|
h.handleSubscribe(ctx, req)
|
|
|
|
case req := <-h.unsubscribeCh:
|
|
h.handleUnsubscribe(ctx, req)
|
|
|
|
case req := <-h.broadcastCh:
|
|
h.handleBroadcast(ctx, req)
|
|
|
|
case conn := <-h.disconnectCh:
|
|
h.handleDisconnect(ctx, conn)
|
|
|
|
case evt := <-h.remoteEventCh:
|
|
h.handleRemoteEvent(evt)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Hub) handleSubscribe(ctx context.Context, req *subscribeRequest) {
|
|
if err := h.authorizer.Authorize(ctx, req.channelID, req.conn.humanID); err != nil {
|
|
req.conn.Send(ServerMessage{
|
|
Type: TypeError,
|
|
Channel: req.channelID,
|
|
Message: "unauthorized",
|
|
})
|
|
return
|
|
}
|
|
|
|
ch, ok := h.channels[req.channelID]
|
|
if !ok {
|
|
ch = newChannel(req.channelID)
|
|
h.channels[req.channelID] = ch
|
|
}
|
|
|
|
// Capture before addMember so multi-tab joins don't emit a spurious join.
|
|
wasPresentLocally := ch.hasHumanID(req.conn.humanID)
|
|
|
|
ch.addMember(req.conn, req.conn.humanID)
|
|
|
|
if h.connChannels[req.conn] == nil {
|
|
h.connChannels[req.conn] = make(map[string]bool)
|
|
}
|
|
h.connChannels[req.conn][req.channelID] = true
|
|
|
|
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)
|
|
// Fall back to local-only presence.
|
|
presence = ch.localHumanIDs()
|
|
}
|
|
|
|
req.conn.Send(ServerMessage{
|
|
Type: TypeSubscribed,
|
|
Channel: req.channelID,
|
|
Presence: presence,
|
|
})
|
|
|
|
// Redis self-filter drops our own echo, so same-pod peers need a direct nudge.
|
|
if !wasPresentLocally {
|
|
ch.broadcast(ServerMessage{
|
|
Type: TypeJoin,
|
|
Channel: req.channelID,
|
|
HumanID: req.conn.humanID,
|
|
}, req.conn)
|
|
}
|
|
}
|
|
|
|
func (h *Hub) handleUnsubscribe(ctx context.Context, req *unsubscribeRequest) {
|
|
ch, ok := h.channels[req.channelID]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
ch.removeMember(req.conn)
|
|
|
|
if chans, ok := h.connChannels[req.conn]; ok {
|
|
delete(chans, req.channelID)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Only emit leave once the humanID has no remaining tabs on this pod.
|
|
if !ch.hasHumanID(req.conn.humanID) {
|
|
ch.broadcast(ServerMessage{
|
|
Type: TypeLeave,
|
|
Channel: req.channelID,
|
|
HumanID: req.conn.humanID,
|
|
}, req.conn)
|
|
}
|
|
|
|
if ch.isEmpty() {
|
|
delete(h.channels, req.channelID)
|
|
}
|
|
}
|
|
|
|
func (h *Hub) handleBroadcast(ctx context.Context, req *broadcastRequest) {
|
|
ch, ok := h.channels[req.channelID]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
if _, isMember := ch.members[req.conn]; !isMember {
|
|
req.conn.sendError("not subscribed to channel: " + req.channelID)
|
|
return
|
|
}
|
|
|
|
// Local fanout (excluding sender), then publish for other pods.
|
|
ch.broadcast(ServerMessage{
|
|
Type: TypeMessage,
|
|
Channel: req.channelID,
|
|
HumanID: req.conn.humanID,
|
|
Payload: req.payload,
|
|
}, req.conn)
|
|
|
|
h.bridge.Broadcast(ctx, req.channelID, req.conn.humanID, req.payload)
|
|
}
|
|
|
|
func (h *Hub) handleDisconnect(ctx context.Context, conn *Conn) {
|
|
chans, ok := h.connChannels[conn]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
for channelID := range chans {
|
|
ch, ok := h.channels[channelID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
ch.removeMember(conn)
|
|
|
|
if err := h.bridge.Unsubscribe(ctx, channelID, conn.id, conn.humanID); err != nil {
|
|
slog.Error("redis unsubscribe on disconnect failed", "channelId", channelID, "error", err)
|
|
}
|
|
|
|
if !ch.hasHumanID(conn.humanID) {
|
|
ch.broadcast(ServerMessage{
|
|
Type: TypeLeave,
|
|
Channel: channelID,
|
|
HumanID: conn.humanID,
|
|
}, conn)
|
|
}
|
|
|
|
if ch.isEmpty() {
|
|
delete(h.channels, channelID)
|
|
}
|
|
}
|
|
|
|
delete(h.connChannels, conn)
|
|
}
|
|
|
|
func (h *Hub) handleRemoteEvent(evt *remoteEvent) {
|
|
ch, ok := h.channels[evt.channelID]
|
|
if !ok {
|
|
// No local subscribers — drop the event.
|
|
return
|
|
}
|
|
|
|
switch evt.event.Type {
|
|
case TypeJoin:
|
|
ch.broadcast(ServerMessage{
|
|
Type: TypeJoin,
|
|
Channel: evt.channelID,
|
|
HumanID: evt.event.HumanID,
|
|
}, nil)
|
|
|
|
case TypeLeave:
|
|
ch.broadcast(ServerMessage{
|
|
Type: TypeLeave,
|
|
Channel: evt.channelID,
|
|
HumanID: evt.event.HumanID,
|
|
}, nil)
|
|
|
|
case TypeMessage:
|
|
ch.broadcast(ServerMessage{
|
|
Type: TypeMessage,
|
|
Channel: evt.channelID,
|
|
HumanID: evt.event.HumanID,
|
|
Payload: evt.event.Payload,
|
|
}, nil)
|
|
}
|
|
}
|
|
|
|
func (h *Hub) Subscribe(conn *Conn, channelID string) {
|
|
h.subscribeCh <- &subscribeRequest{conn: conn, channelID: channelID}
|
|
}
|
|
|
|
func (h *Hub) disconnect(conn *Conn) {
|
|
h.disconnectCh <- conn
|
|
}
|