package server import ( "encoding/json" "net/http" "time" "github.com/coder/websocket" "termd/internal/api" ) // Attach websocket protocol: binary frames are raw terminal bytes in both // directions; text frames are JSON control messages — client sends // {"resize":{"cols":N,"rows":N}}, server sends {"hello":{...}} on connect and // {"exited":{"code":N}} when the process dies. type attachControl struct { Hello *api.SessionInfo `json:"hello,omitempty"` Resize *api.ResizeRequest `json:"resize,omitempty"` Exited *attachExited `json:"exited,omitempty"` } type attachExited struct { Code int `json:"code"` } func (s *Server) handleAttach(w http.ResponseWriter, r *http.Request) { sess := s.getSession(w, r) if sess == nil { return } readonly := r.URL.Query().Get("readonly") != "" conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ // Local research tool; the bearer token (when set) is the auth story. OriginPatterns: []string{"*"}, }) if err != nil { // Accept has already written the HTTP error. return } defer conn.CloseNow() ctx := r.Context() writeControl := func(c attachControl) error { b, err := json.Marshal(c) if err != nil { return err } return conn.Write(ctx, websocket.MessageText, b) } info := sess.Info() if err := writeControl(attachControl{Hello: &info}); err != nil { return } // view=render streams debounced emulator-rendered frames (absolute // positioning, lines cleared to the viewer's edge) instead of the raw // byte stream — for viewers whose terminal is larger than the session. render := r.URL.Query().Get("view") == "render" snapshot, ch, cancel := sess.Subscribe() defer cancel() if render { snapshot = sess.RenderFrame(true) } if err := conn.Write(ctx, websocket.MessageBinary, snapshot); err != nil { return } // Client-to-session direction. go func() { for { typ, data, err := conn.Read(ctx) if err != nil { cancel() // closes ch, ends the send loop below return } switch typ { case websocket.MessageBinary: if readonly { // Read-only connections must not reach the PTY. continue } if err := sess.WriteHuman(data); err != nil { cancel() return } case websocket.MessageText: var c attachControl if err := json.Unmarshal(data, &c); err == nil && c.Resize != nil { // Last-writer-wins, tmux "latest client" style. _ = sess.Resize(c.Resize.Cols, c.Resize.Rows) } } } }() exitedCh, _ := sess.Exited() // In render mode, output chunks only mark the frame dirty; the actual // frame is sent when the debounce window closes, coalescing bursts. var frameDue <-chan time.Time for { select { case b, ok := <-ch: if !ok { // Session output ended (teardown or we were dropped as a slow // client). If the process exited, tell the client the code. if render { _ = conn.Write(ctx, websocket.MessageBinary, sess.RenderFrame(false)) } select { case <-exitedCh: _, code := sess.Exited() _ = writeControl(attachControl{Exited: &attachExited{Code: code}}) case <-time.After(time.Second): } _ = conn.Close(websocket.StatusNormalClosure, "session ended") return } if render { if frameDue == nil { frameDue = time.After(25 * time.Millisecond) } } else if err := conn.Write(ctx, websocket.MessageBinary, b); err != nil { return } case <-frameDue: frameDue = nil if err := conn.Write(ctx, websocket.MessageBinary, sess.RenderFrame(false)); err != nil { return } case <-exitedCh: _, code := sess.Exited() _ = writeControl(attachControl{Exited: &attachExited{Code: code}}) exitedCh = nil // fire once; keep draining trailing output case <-ctx.Done(): return } } }