github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/internal/hud/server/websocket.go (about) 1 package server 2 3 import ( 4 "context" 5 "fmt" 6 "io" 7 "net/http" 8 "sort" 9 "sync" 10 "time" 11 12 "google.golang.org/protobuf/types/known/timestamppb" 13 metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 14 "k8s.io/apimachinery/pkg/types" 15 "k8s.io/client-go/util/workqueue" 16 ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" 17 18 "github.com/grpc-ecosystem/grpc-gateway/runtime" 19 20 "github.com/tilt-dev/tilt/internal/hud/server/gorilla" 21 "github.com/tilt-dev/tilt/internal/hud/webview" 22 "github.com/tilt-dev/tilt/internal/store" 23 "github.com/tilt-dev/tilt/pkg/apis/core/v1alpha1" 24 "github.com/tilt-dev/tilt/pkg/logger" 25 "github.com/tilt-dev/tilt/pkg/model/logstore" 26 proto_webview "github.com/tilt-dev/tilt/pkg/webview" 27 28 "github.com/gorilla/websocket" 29 ) 30 31 var upgrader = websocket.Upgrader{ 32 ReadBufferSize: 1024, 33 WriteBufferSize: 1024, 34 35 // Disable compression due to safari bugs in websockets, see: 36 // https://github.com/tilt-dev/tilt/issues/4746 37 // 38 // Though, frankly, we probably don't need compression 39 // anyway, since it's not like you're using Tilt over 40 // a mobile network. 41 EnableCompression: false, 42 43 // Allow the connection if either: 44 // 45 // 1) The client has a CSRF token, or 46 // 2) The origin matches what we expect. 47 // 48 // Once a few releases have gone by we should remove the origin check. 49 // (since we know some tilt users expect tabs to stay open 50 // across releases). 51 CheckOrigin: func(req *http.Request) bool { 52 if websocketCSRFToken.String() == req.URL.Query().Get("csrf") { 53 return true 54 } 55 56 // If the CSRF check fails, fallback to an origin check. 57 return gorilla.CheckSameOrigin(req) 58 }, 59 } 60 61 type WebsocketSubscriber struct { 62 ctx context.Context 63 st store.RStore 64 ctrlClient ctrlclient.Client 65 mu sync.Mutex 66 conn WebsocketConn 67 68 q workqueue.Interface 69 dirtyUIResources map[string]*v1alpha1.UIResource 70 dirtyUIButtons map[string]*v1alpha1.UIButton 71 dirtyUISession *v1alpha1.UISession 72 dirtyClusters map[string]*v1alpha1.Cluster 73 74 tiltStartTime *timestamppb.Timestamp 75 clientCheckpoint logstore.Checkpoint 76 } 77 78 type WebsocketConn interface { 79 NextReader() (int, io.Reader, error) 80 Close() error 81 NextWriter(messageType int) (io.WriteCloser, error) 82 } 83 84 var _ WebsocketConn = &websocket.Conn{} 85 86 func NewWebsocketSubscriber(ctx context.Context, ctrlClient ctrlclient.Client, st store.RStore, conn WebsocketConn) *WebsocketSubscriber { 87 return &WebsocketSubscriber{ 88 ctx: ctx, 89 ctrlClient: ctrlClient, 90 st: st, 91 conn: conn, 92 q: workqueue.New(), 93 dirtyUIButtons: make(map[string]*v1alpha1.UIButton), 94 dirtyUIResources: make(map[string]*v1alpha1.UIResource), 95 dirtyClusters: make(map[string]*v1alpha1.Cluster), 96 } 97 } 98 99 func (ws *WebsocketSubscriber) TearDown(ctx context.Context) { 100 _ = ws.conn.Close() 101 } 102 103 // Should be called exactly once. Consumes messages until the socket closes. 104 func (ws *WebsocketSubscriber) Stream(ctx context.Context) { 105 ctx, cancel := context.WithCancel(ctx) 106 defer cancel() 107 108 go func() { 109 // No-op consumption of all control messages, as recommended here: 110 // https://godoc.org/github.com/gorilla/websocket#hdr-Control_Messages 111 conn := ws.conn 112 for { 113 _, _, err := conn.NextReader() 114 if err != nil { 115 ws.q.ShutDown() 116 cancel() 117 break 118 } 119 } 120 }() 121 122 // initialize the stream with a full view 123 view, err := webview.CompleteView(ctx, ws.ctrlClient, ws.st) 124 if err != nil { 125 // not much to do 126 return 127 } 128 129 ws.sendView(ctx, view) 130 131 if view.UiSession != nil { 132 ws.onSessionUpdateSent(ctx, view.UiSession) 133 } 134 135 debouncer := time.NewTimer(200 * time.Millisecond) 136 defer func() { 137 if !debouncer.Stop() { 138 <-debouncer.C 139 } 140 }() 141 for { 142 item, shutdown := ws.q.Get() 143 if shutdown { 144 return 145 } 146 147 view := ws.toViewUpdate() 148 if view != nil { 149 ws.sendView(ctx, view) 150 151 if view.UiSession != nil { 152 ws.onSessionUpdateSent(ctx, view.UiSession) 153 } 154 } 155 156 ws.q.Done(item) 157 158 select { 159 case <-debouncer.C: 160 case <-ctx.Done(): 161 } 162 debouncer.Reset(200 * time.Millisecond) 163 } 164 } 165 166 func (ws *WebsocketSubscriber) OnChange(ctx context.Context, s store.RStore, summary store.ChangeSummary) error { 167 // Currently, we only broadcast log changes from this OnChange handler. 168 // Everything else should be handled by reconcilers from the apiserver 169 if !summary.Log { 170 return nil 171 } 172 173 ws.q.Add(true) 174 return nil 175 } 176 177 // Sends a UISession update on the websocket. 178 func (ws *WebsocketSubscriber) SendUISessionUpdate(ctx context.Context, uiSession *v1alpha1.UISession) { 179 ws.mu.Lock() 180 ws.dirtyUISession = uiSession 181 ws.mu.Unlock() 182 183 ws.q.Add(true) 184 } 185 186 // If a session update triggered an analytics nudge, record it so that we don't 187 // nudge again. 188 func (ws *WebsocketSubscriber) onSessionUpdateSent(ctx context.Context, uiSession *v1alpha1.UISession) { 189 state := ws.st.RLockState() 190 surfaced := !state.AnalyticsNudgeSurfaced 191 ws.st.RUnlockState() 192 193 if uiSession != nil && uiSession.Status.NeedsAnalyticsNudge && !surfaced { 194 // If we're showing the nudge and no one's told the engine 195 // state about it yet... tell the engine state. 196 ws.st.Dispatch(store.AnalyticsNudgeSurfacedAction{}) 197 } 198 } 199 200 // Sends a UIResource update on the websocket. 201 func (ws *WebsocketSubscriber) SendUIResourceUpdate(ctx context.Context, nn types.NamespacedName, uiResource *v1alpha1.UIResource) { 202 if uiResource == nil { 203 // If the UI resource doesn't exist, send a fake one down the 204 // stream that the UI will interpret as deletion. 205 now := metav1.Now() 206 uiResource = &v1alpha1.UIResource{ 207 ObjectMeta: metav1.ObjectMeta{ 208 Name: nn.Name, 209 DeletionTimestamp: &now, 210 }, 211 } 212 } 213 214 ws.mu.Lock() 215 ws.dirtyUIResources[nn.Name] = uiResource 216 ws.mu.Unlock() 217 ws.q.Add(true) 218 } 219 220 // Sends a UIButton update on the websocket. 221 func (ws *WebsocketSubscriber) SendUIButtonUpdate(ctx context.Context, nn types.NamespacedName, uiButton *v1alpha1.UIButton) { 222 if uiButton == nil { 223 // If the UI button doesn't exist, send a fake one down the 224 // stream that the UI will interpret as deletion. 225 now := metav1.Now() 226 uiButton = &v1alpha1.UIButton{ 227 ObjectMeta: metav1.ObjectMeta{ 228 Name: nn.Name, 229 DeletionTimestamp: &now, 230 }, 231 } 232 } 233 234 ws.mu.Lock() 235 ws.dirtyUIButtons[nn.Name] = uiButton 236 ws.mu.Unlock() 237 ws.q.Add(true) 238 } 239 240 func (ws *WebsocketSubscriber) SendClusterUpdate( 241 _ context.Context, 242 nn types.NamespacedName, 243 cluster *v1alpha1.Cluster, 244 ) { 245 if cluster == nil { 246 // If the cluster doesn't exist, send a fake one down the 247 // stream that the UI will interpret as deletion. 248 now := metav1.Now() 249 cluster = &v1alpha1.Cluster{ 250 ObjectMeta: metav1.ObjectMeta{ 251 Name: nn.Name, 252 DeletionTimestamp: &now, 253 }, 254 } 255 } 256 257 ws.mu.Lock() 258 ws.dirtyClusters[nn.Name] = cluster 259 ws.mu.Unlock() 260 ws.q.Add(true) 261 } 262 263 // Sends all the objects that have changed since the last send. 264 func (ws *WebsocketSubscriber) toViewUpdate() *proto_webview.View { 265 view, err := webview.LogUpdate(ws.st, ws.clientCheckpoint) 266 if err != nil { 267 return nil // Not much we can do on error right now. 268 } 269 270 ws.mu.Lock() 271 defer ws.mu.Unlock() 272 273 // [-1,-1) means there are no logs 274 if view.LogList.ToCheckpoint == -1 && view.LogList.FromCheckpoint == -1 { 275 view.LogList = nil 276 } 277 hasChanges := view.LogList != nil 278 279 if ws.dirtyUISession != nil { 280 view.UiSession = ws.dirtyUISession 281 ws.dirtyUISession = nil 282 hasChanges = true 283 } 284 285 for k, obj := range ws.dirtyUIResources { 286 view.UiResources = append(view.UiResources, obj) 287 delete(ws.dirtyUIResources, k) 288 hasChanges = true 289 } 290 sort.Slice(view.UiResources, func(i, j int) bool { 291 return view.UiResources[i].Name < view.UiResources[j].Name 292 }) 293 294 for k, obj := range ws.dirtyUIButtons { 295 view.UiButtons = append(view.UiButtons, obj) 296 delete(ws.dirtyUIButtons, k) 297 hasChanges = true 298 } 299 sort.Slice(view.UiButtons, func(i, j int) bool { 300 return view.UiButtons[i].Name < view.UiButtons[j].Name 301 }) 302 303 for k, obj := range ws.dirtyClusters { 304 view.Clusters = append(view.Clusters, obj) 305 delete(ws.dirtyClusters, k) 306 hasChanges = true 307 } 308 sort.Slice(view.Clusters, func(i, j int) bool { 309 return view.Clusters[i].Name < view.Clusters[j].Name 310 }) 311 312 if !hasChanges { 313 return nil 314 } 315 return view 316 } 317 318 // Sends the view to the websocket. 319 func (ws *WebsocketSubscriber) sendView(ctx context.Context, view *proto_webview.View) { 320 if view.LogList != nil && view.LogList.ToCheckpoint != -1 { 321 ws.clientCheckpoint = logstore.Checkpoint(view.LogList.ToCheckpoint) 322 } 323 324 // A little hack that initializes tiltStartTime for this websocket 325 // on the first send. 326 if ws.tiltStartTime == nil { 327 ws.tiltStartTime = view.TiltStartTime 328 } 329 330 jsEncoder := &runtime.JSONPb{} 331 w, err := ws.conn.NextWriter(websocket.TextMessage) 332 if err != nil { 333 logger.Get(ctx).Verbosef("getting writer: %v", err) 334 return 335 } 336 defer func() { 337 err := w.Close() 338 if err != nil { 339 logger.Get(ctx).Verbosef("error closing websocket writer: %v", err) 340 } 341 }() 342 343 err = jsEncoder.NewEncoder(w).Encode(view) 344 if err != nil { 345 logger.Get(ctx).Verbosef("sending webview data: %v", err) 346 } 347 } 348 349 func (s *HeadsUpServer) ViewWebsocket(w http.ResponseWriter, req *http.Request) { 350 conn, err := upgrader.Upgrade(w, req, nil) 351 if err != nil { 352 http.Error(w, fmt.Sprintf("Error upgrading websocket: %v", err), http.StatusInternalServerError) 353 return 354 } 355 356 ws := NewWebsocketSubscriber(s.ctx, s.ctrlClient, s.store, conn) 357 s.wsList.Add(ws) 358 _ = s.store.AddSubscriber(s.ctx, ws) 359 360 ws.Stream(s.ctx) 361 362 // When we remove ourselves as a subscriber, the Store waits for any outstanding OnChange 363 // events to complete, then calls TearDown. 364 _ = s.store.RemoveSubscriber(context.Background(), ws) 365 s.wsList.Remove(ws) 366 } 367 368 var _ store.TearDowner = &WebsocketSubscriber{}