forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | // Package terminal gives the browser an interactive shell: each WebSocket |
| 2 | // connection to its handler spawns one shell inside a pseudo-terminal and | |
| 3 | // pipes bytes both ways, which is exactly what xterm.js expects to drive. | |
| 4 | // | |
| 5 | // Wire protocol: the server sends raw terminal output as binary frames; the | |
| 6 | // client sends JSON text frames — {"type":"input","data":"…"} for keystrokes | |
| 7 | // and {"type":"resize","cols":N,"rows":N} when the viewport changes. | |
| 8 | package terminal | |
| 9 | ||
| 10 | import ( | |
| 11 | "context" | |
| 12 | "encoding/json" | |
| 13 | "log/slog" | |
| 14 | "net/http" | |
| 15 | "os" | |
| 16 | "os/exec" | |
| 17 | ||
| 18 | "github.com/coder/websocket" | |
| 19 | "github.com/creack/pty" | |
| 20 | ) | |
| 21 | ||
| 22 | // Service spawns one shell per WebSocket connection. | |
| 23 | type Service struct { | |
| 24 | // Shell is the command to run; when empty, $SHELL then bash then sh. | |
| 25 | Shell []string | |
| 26 | // Cwd is the directory each shell starts in. | |
| 27 | Cwd string | |
| 28 | ||
| 29 | logger *slog.Logger | |
| 30 | } | |
| 31 | ||
| 32 | // New creates a terminal service starting shells in cwd. | |
| 33 | // | |
| 34 | // Example: | |
| 35 | // | |
| 36 | // mux.Handle("GET /ws/terminal", terminal.New(cfg.Cwd, logger).WebSocketHandler()) | |
| 37 | func New(cwd string, logger *slog.Logger) *Service { | |
| 38 | if logger == nil { | |
| 39 | logger = slog.Default() | |
| 40 | } | |
| 41 | return &Service{Cwd: cwd, logger: logger} | |
| 42 | } | |
| 43 | ||
| 44 | // controlMessage is what the client sends over text frames. | |
| 45 | type controlMessage struct { | |
| 46 | Type string `json:"type"` | |
| 47 | Data string `json:"data,omitempty"` | |
| 48 | Cols uint16 `json:"cols,omitempty"` | |
| 49 | Rows uint16 `json:"rows,omitempty"` | |
| 50 | } | |
| 51 | ||
| 52 | // WebSocketHandler upgrades the request and runs one shell session until the | |
| 53 | // socket closes or the shell exits. | |
| 54 | func (s *Service) WebSocketHandler() http.Handler { | |
| 55 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 56 | conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ | |
| 57 | OriginPatterns: []string{"localhost:*", "127.0.0.1:*"}, | |
| 58 | }) | |
| 59 | if err != nil { | |
| 60 | s.logger.Warn("terminal websocket accept failed", "error", err) | |
| 61 | return | |
| 62 | } | |
| 63 | defer func() { _ = conn.CloseNow() }() | |
| 64 | s.serveShell(r.Context(), conn) | |
| 65 | }) | |
| 66 | } | |
| 67 | ||
| 68 | // serveShell runs the shell and the two pumps for one connection. | |
| 69 | func (s *Service) serveShell(ctx context.Context, conn *websocket.Conn) { | |
| 70 | cmd := exec.Command(s.shellCommand()[0], s.shellCommand()[1:]...) | |
| 71 | cmd.Dir = s.Cwd | |
| 72 | cmd.Env = append(os.Environ(), "TERM=xterm-256color") | |
| 73 | ||
| 74 | ptmx, err := pty.Start(cmd) | |
| 75 | if err != nil { | |
| 76 | s.logger.Error("pty start failed", "error", err) | |
| 77 | _ = conn.Close(websocket.StatusInternalError, "pty start failed") | |
| 78 | return | |
| 79 | } | |
| 80 | defer func() { | |
| 81 | _ = ptmx.Close() | |
| 82 | _ = cmd.Process.Kill() | |
| 83 | _ = cmd.Wait() | |
| 84 | }() | |
| 85 | ||
| 86 | // Output pump: pty → socket, as binary frames. | |
| 87 | outputDone := make(chan struct{}) | |
| 88 | go func() { | |
| 89 | defer close(outputDone) | |
| 90 | buffer := make([]byte, 8192) | |
| 91 | for { | |
| 92 | n, readErr := ptmx.Read(buffer) | |
| 93 | if n > 0 { | |
| 94 | if writeErr := conn.Write(ctx, websocket.MessageBinary, buffer[:n]); writeErr != nil { | |
| 95 | return | |
| 96 | } | |
| 97 | } | |
| 98 | if readErr != nil { | |
| 99 | // The shell exited (or the pty closed): tell the client. | |
| 100 | _ = conn.Close(websocket.StatusNormalClosure, "shell exited") | |
| 101 | return | |
| 102 | } | |
| 103 | } | |
| 104 | }() | |
| 105 | ||
| 106 | // Input pump: socket → pty, until the client disconnects. | |
| 107 | s.readControlMessages(ctx, conn, ptmx) | |
| 108 | <-outputDone | |
| 109 | } | |
| 110 | ||
| 111 | // readControlMessages feeds client frames into the pty. | |
| 112 | func (s *Service) readControlMessages(ctx context.Context, conn *websocket.Conn, ptmx *os.File) { | |
| 113 | for { | |
| 114 | kind, data, err := conn.Read(ctx) | |
| 115 | if err != nil { | |
| 116 | return | |
| 117 | } | |
| 118 | if kind != websocket.MessageText { | |
| 119 | continue | |
| 120 | } | |
| 121 | var msg controlMessage | |
| 122 | if err := json.Unmarshal(data, &msg); err != nil { | |
| 123 | continue // a malformed frame must not kill the session | |
| 124 | } | |
| 125 | switch msg.Type { | |
| 126 | case "input": | |
| 127 | _, _ = ptmx.WriteString(msg.Data) | |
| 128 | case "resize": | |
| 129 | if msg.Cols > 0 && msg.Rows > 0 { | |
| 130 | _ = pty.Setsize(ptmx, &pty.Winsize{Cols: msg.Cols, Rows: msg.Rows}) | |
| 131 | } | |
| 132 | } | |
| 133 | } | |
| 134 | } | |
| 135 | ||
| 136 | // shellCommand picks the shell to run: the configured one, else $SHELL, else | |
| 137 | // bash, else sh. | |
| 138 | func (s *Service) shellCommand() []string { | |
| 139 | if len(s.Shell) > 0 { | |
| 140 | return s.Shell | |
| 141 | } | |
| 142 | if shell := os.Getenv("SHELL"); shell != "" { | |
| 143 | return []string{shell, "-l"} | |
| 144 | } | |
| 145 | if path, err := exec.LookPath("bash"); err == nil { | |
| 146 | return []string{path, "-l"} | |
| 147 | } | |
| 148 | return []string{"sh"} | |
| 149 | } |