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{}