Easier for testing, and omits need for full dep injection for pusher service, where we passed in nil for deps dangerously.
125 lines
3.8 KiB
Go
125 lines
3.8 KiB
Go
package pusher
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/flowy-live/llink/genproto/llink/pusher"
|
|
"github.com/flowy-live/llink/internal/auth"
|
|
"github.com/google/uuid"
|
|
"nhooyr.io/websocket"
|
|
)
|
|
|
|
// Server handles WebSocket upgrades and gRPC presence queries.
|
|
type Server struct {
|
|
pbpusher.UnimplementedPusherServiceServer
|
|
|
|
ctx context.Context // server-scoped context for graceful shutdown
|
|
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,
|
|
hub: hub,
|
|
bridge: bridge,
|
|
authSvc: authSvc,
|
|
}
|
|
}
|
|
|
|
// 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 := r.URL.Query().Get("token")
|
|
if token == "" {
|
|
http.Error(w, "token required", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
session, err := s.authSvc.GetSession(r.Context(), token)
|
|
if err != nil {
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
// Accept WebSocket upgrade
|
|
ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
|
// Allow all origins for now — CORS is handled at the gateway level
|
|
InsecureSkipVerify: true,
|
|
})
|
|
if err != nil {
|
|
slog.Error("websocket accept failed", "error", err)
|
|
return
|
|
}
|
|
|
|
connID := uuid.New().String()
|
|
conn := newConn(connID, session.HumanId, ws)
|
|
|
|
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.
|
|
ctx, cancel := context.WithCancel(s.ctx)
|
|
defer cancel()
|
|
|
|
// Auto-subscribe to 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 {
|
|
return nil, err
|
|
}
|
|
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 {
|
|
return nil, err
|
|
}
|
|
onlineSet := make(map[string]bool, len(allOnline))
|
|
for _, id := range allOnline {
|
|
onlineSet[id] = true
|
|
}
|
|
result := make(map[string]bool, len(req.HumanIds))
|
|
for _, id := range req.HumanIds {
|
|
result[id] = onlineSet[id]
|
|
}
|
|
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 {
|
|
return nil, err
|
|
}
|
|
resp := &pbpusher.GetChannelPresenceResponse{
|
|
Presences: make(map[string]*pbpusher.ChannelPresence, len(presence)),
|
|
}
|
|
for chID, humanIDs := range presence {
|
|
resp.Presences[chID] = &pbpusher.ChannelPresence{
|
|
HumanIds: humanIDs,
|
|
}
|
|
}
|
|
return resp, nil
|
|
}
|