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.AuthService } // 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.AuthService) *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 }