1f7a5d96a7
R opens a navigable overlay of recent sessions (GET /sessions: short id, status,
workflow/stage, relative age); enter resumes via POST /sessions/{id}/resume, which
replays onto the existing global /stream (no WS re-dial). Fetch/resume errors surface
in the overlay, never panic. stdlib-only (ws.Client.HTTPBase derives the base URL).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
195 lines
4.2 KiB
Go
195 lines
4.2 KiB
Go
// Package ws is the WebSocket transport to apps/server. It owns a reconnecting
|
|
// connection and exposes decoded server messages plus connection-status changes
|
|
// over channels the Bubble Tea loop drains via commands.
|
|
package ws
|
|
|
|
import (
|
|
"context"
|
|
"net/url"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/correx/tui-go/internal/protocol"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
const (
|
|
initialBackoff = 500 * time.Millisecond
|
|
maxBackoff = 8 * time.Second
|
|
)
|
|
|
|
// Status reports connection lifecycle transitions.
|
|
type Status struct {
|
|
Connected bool
|
|
Reconnecting bool
|
|
Attempt int
|
|
}
|
|
|
|
// Client is a reconnecting WebSocket client. Construct with New, then Run in a
|
|
// goroutine. Incoming and Conn are drained by the UI.
|
|
type Client struct {
|
|
url string
|
|
httpBase string
|
|
send chan []byte
|
|
in chan protocol.ServerMessage
|
|
conn chan Status
|
|
}
|
|
|
|
// New builds a client targeting ws://host:port/stream.
|
|
func New(host string, port int) *Client {
|
|
hp := hostPort(host, port)
|
|
u := url.URL{Scheme: "ws", Host: hp, Path: "/stream"}
|
|
return &Client{
|
|
url: u.String(),
|
|
httpBase: (&url.URL{Scheme: "http", Host: hp}).String(),
|
|
send: make(chan []byte, 64),
|
|
in: make(chan protocol.ServerMessage, 256),
|
|
conn: make(chan Status, 16),
|
|
}
|
|
}
|
|
|
|
// HTTPBase is the http://host:port origin the WS client connects to, for REST
|
|
// calls (e.g. GET /sessions) that share the server's host/port. Empty for a nil
|
|
// client (the test harness constructs Models with a nil client).
|
|
func (c *Client) HTTPBase() string {
|
|
if c == nil {
|
|
return ""
|
|
}
|
|
return c.httpBase
|
|
}
|
|
|
|
func hostPort(host string, port int) string {
|
|
h := host
|
|
if h == "" {
|
|
h = "localhost"
|
|
}
|
|
return h + ":" + itoa(port)
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
var b [20]byte
|
|
i := len(b)
|
|
for n > 0 {
|
|
i--
|
|
b[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
return string(b[i:])
|
|
}
|
|
|
|
// Incoming yields decoded server messages.
|
|
func (c *Client) Incoming() <-chan protocol.ServerMessage { return c.in }
|
|
|
|
// Conn yields connection-status changes.
|
|
func (c *Client) Conn() <-chan Status { return c.conn }
|
|
|
|
// Send queues a raw client frame. Non-blocking; drops if the buffer is full
|
|
// (the server will be resynced on reconnect via snapshot).
|
|
func (c *Client) Send(frame []byte) {
|
|
select {
|
|
case c.send <- frame:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Drain returns all queued-but-unsent client frames. Test-only: meaningful before
|
|
// Run starts consuming the send channel, so a test can assert on what was Sent.
|
|
func (c *Client) Drain() [][]byte {
|
|
var out [][]byte
|
|
for {
|
|
select {
|
|
case f := <-c.send:
|
|
out = append(out, f)
|
|
default:
|
|
return out
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run drives the reconnect loop until ctx is cancelled.
|
|
func (c *Client) Run(ctx context.Context) {
|
|
backoff := initialBackoff
|
|
attempt := 0
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.url, nil)
|
|
if err != nil {
|
|
attempt++
|
|
c.emit(Status{Reconnecting: true, Attempt: attempt})
|
|
if !sleep(ctx, backoff) {
|
|
return
|
|
}
|
|
backoff = min(backoff*2, maxBackoff)
|
|
continue
|
|
}
|
|
backoff = initialBackoff
|
|
attempt = 0
|
|
c.emit(Status{Connected: true})
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
_ = conn.WriteMessage(websocket.TextMessage, protocol.Hello(cwd))
|
|
}
|
|
c.pump(ctx, conn)
|
|
c.emit(Status{Reconnecting: true})
|
|
}
|
|
}
|
|
|
|
// pump runs the read/write loops for a single live connection and returns when
|
|
// it drops.
|
|
func (c *Client) pump(ctx context.Context, conn *websocket.Conn) {
|
|
connCtx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-connCtx.Done():
|
|
return
|
|
case frame := <-c.send:
|
|
if err := conn.WriteMessage(websocket.TextMessage, frame); err != nil {
|
|
cancel()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}()
|
|
|
|
for {
|
|
_, raw, err := conn.ReadMessage()
|
|
if err != nil {
|
|
cancel()
|
|
_ = conn.Close()
|
|
return
|
|
}
|
|
if msg, derr := protocol.Decode(raw); derr == nil {
|
|
select {
|
|
case c.in <- msg:
|
|
case <-connCtx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *Client) emit(s Status) {
|
|
select {
|
|
case c.conn <- s:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func sleep(ctx context.Context, d time.Duration) bool {
|
|
t := time.NewTimer(d)
|
|
defer t.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-t.C:
|
|
return true
|
|
}
|
|
}
|