package pusher import ( "context" "net/http" "github.com/google/uuid" "nhooyr.io/websocket" pbpusher "github.com/flowy-live/llink/genproto/llink/pusher" "github.com/flowy-live/llink/internal/auth" "github.com/flowy-live/llink/internal/utils/flog" ) // Server handles WebSocket upgrades and gRPC presence queries. type Server struct { pbpusher.UnimplementedPusherServiceServer ctx context.Context // server-scoped; cancelling closes all WebSockets gracefully hub *Hub bridge *RedisBridge authSvc auth.SessionReader } func NewServer(ctx context.Context, hub *Hub, bridge *RedisBridge, authSvc auth.SessionReader) *Server { return &Server{ ctx: ctx, hub: hub, bridge: bridge, authSvc: authSvc, } } func (s *Server) HandleWebSocket(w http.ResponseWriter, r *http.Request) { // 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) return } session, err := s.authSvc.GetSession(r.Context(), token) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ // CORS is enforced at the gateway. InsecureSkipVerify: true, }) if err != nil { flog.Error("websocket accept failed", "error", err) return } connID := uuid.New().String() conn := newConn(connID, session.HumanId, ws) flog.Info("websocket connected", "connId", connID, "humanId", session.HumanId) // 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 the presence channel so this user appears online. s.hub.Subscribe(conn, "_presence:"+session.HumanId) go conn.WritePump(ctx) conn.ReadPump(ctx, s.hub) flog.Info("websocket disconnected", "connId", connID, "humanId", session.HumanId) } 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 } 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 } 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 }