termd/internal/server/attach.go
2026-07-02 22:13:49 -03:00

142 lines
3.7 KiB
Go

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