142 lines
3.7 KiB
Go
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
|
|
}
|
|
}
|
|
}
|