Add WebSocket connections and live message inspection

Support interactive text and binary messages, retained streaming history,
and bounded headless exchanges. Reuse the streaming log and inspectors,
and prevent duplicate sends from mouse activation.

Amp-Thread-ID: https://ampcode.com/threads/T-01a13cfa-a601-73cf-a79f-fd009adf688c
Co-authored-by: Darren Burns <darrenb900@gmail.com>
This commit is contained in:
Amp
2026-10-15 01:29:28 +00:00
co-authored by Darren Burns
parent 06400f5dc9
commit e0bf962b5a
37 changed files with 1265 additions and 95 deletions
+1 -1
View File
@@ -229,7 +229,7 @@ func appConfig(opts options) (ui.Config, error) {
NerdFonts: settings.UseNerdFonts(os.LookupEnv),
HostVariables: host,
UserThemes: userThemes,
Sender: client.ByKind{HTTP: httpClient, GRPC: rpc},
Sender: client.ByKind{HTTP: httpClient, GRPC: rpc, WebSocket: &client.WebSocket{HTTP: httpClient}},
OAuth: oauth,
Describer: rpc,
Collection: root,
+1 -1
View File
@@ -197,7 +197,7 @@ func runCommand(args []string, stdout, stderr io.Writer) int {
}
httpClient := client.NewHTTP("posting/"+version, tls)
httpClient.Root = root
sender := client.ByKind{HTTP: httpClient, GRPC: client.NewGRPC("posting/"+version, tls, root)}
sender := client.ByKind{HTTP: httpClient, GRPC: client.NewGRPC("posting/"+version, tls, root), WebSocket: &client.WebSocket{HTTP: httpClient}}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
for i, req := range requests {
+4
View File
@@ -2,6 +2,10 @@
### Added
- WebSocket requests: connect to `ws://` or `wss://`, send text/JSON or base64 binary messages,
and inspect a timestamped live message log. Incoming messages repaint while idle; inspecting an
older payload pauses following without stopping reception. Disconnect preserves the exchange
in history. TLS, headers, authentication, cookies, variables and bounded headless runs are supported.
- Smart quotes and brackets in structured request bodies, GraphQL queries and variables, gRPC messages, and structured multipart values. Opening delimiters insert a matching closer, selections are wrapped, and typing an existing matching closer moves through it. Enter preserves indentation and expands bracket pairs onto separate lines; paste remains literal.
- Server-sent events (SSE): HTTP responses with `text/event-stream` open a live event log with
timestamps, event names, IDs, and text/JSON inspection. Stop a stream without losing received
+42
View File
@@ -304,6 +304,48 @@ Posting 2 doesn't know about GraphQL requests. It opens them as a `GET` of the U
saving one in Posting 2 removes `kind` and the `graphql` section, which loses the query. Edit
GraphQL requests only in Posting 3.
## WebSocket requests
Choose **WebSocket** in the method selector (++ctrl+t++, then ++w++). Enter a `ws://` or `wss://`
URL and press **Connect** or ++ctrl+j++. Connecting does **not** send the draft in the **Message**
tab. Once connected, **Send** or ++ctrl+j++ sends that draft as one message. Edit it and send again
as often as needed, including empty messages. Enable **Binary (base64)** to send base64-decoded
bytes instead of UTF-8 text.
The **Messages** response tab shows sent and received messages with timestamps. JSON messages
use the existing text and JSON tree inspectors; binary messages are shown as base64. New messages
appear without keyboard or mouse activity. Select an older message to pause following while new
messages accumulate; ++f++ resumes following. ++enter++ opens the full inspector and ++escape++
returns to the log without disconnecting.
Press ++alt+j++ or choose **Disconnect WebSocket** to disconnect. ++escape++ from the log also
disconnects. The log, handshake headers, close code and reason remain available in history.
Unsent queued messages are discarded on disconnect. Reconnection is explicit and never replays
messages. The request timeout bounds connecting and each write; it does not end an established
interactive connection. A caller's deadline still applies.
Headers, query/path parameters, Basic/Bearer/OAuth authentication, cookies, proxy and TLS settings
apply to the upgrade handshake. To offer subprotocols, add a `Sec-WebSocket-Protocol` header.
OAuth requires `wss://` except on loopback. Ping frames receive automatic pong replies. Redirects,
Digest authentication, per-message compression and automatic reconnect are not supported.
Messages are limited to 1 MiB; the connection stops when the retained log reaches 10,000 entries
or 64 MiB of payloads, preserving the log and explaining the limit.
Saved requests use `kind: websocket`:
```yaml
name: Deployment updates
kind: websocket
url: wss://api.example.com/events
websocket:
message: '{"subscribe":"deployments"}'
binary: false
```
Edit these files only in Posting 3. Export as YAML is supported; command export is not.
See [headless WebSocket runs](./running.md#websocket-requests) for the different send/timeout behavior
of `posting run`.
## gRPC requests
To make a gRPC request, open the method selector with ++ctrl+t++ and choose **gRPC**, or press
+16 -1
View File
@@ -10,7 +10,7 @@ posting run api --env dev --json > results.json
jq -r '.results[0].response.body' results.json
```
It uses the same HTTP, GraphQL and gRPC clients as the UI, including authentication, TLS settings,
It uses the same HTTP, GraphQL, gRPC and WebSocket clients as the UI, including authentication, TLS settings,
uploads, request options and variable substitution. It doesn't open tabs, save history, or change
the UI's environment. To send through a Posting someone is watching, use
[`posting remote`](./remote_control.md) instead.
@@ -38,6 +38,21 @@ the whole response. A timeout, interrupted connection or stream size limit fails
`--json` retains the received raw SSE body and includes a `streamError` explanation. Posting never
reconnects automatically.
### WebSocket requests
A headless [WebSocket request](./requests.md#websocket-requests) sends its saved nonempty message
once after connecting, then receives until the server closes or the request timeout expires.
An empty draft listens without sending. Normal close codes 1000 and 1001 pass; timeouts, abnormal
closes and message/log limits fail while retaining completed messages.
The response body is a JSON array of timestamped log entries. `Kind` identifies sent/received
messages, `FrameType` identifies text/binary, and `Body` contains base64-encoded payload bytes
(including for text). This preserves binary data and message boundaries in exports. For example:
```bash
posting run events.posting.yaml --json | jq -r '.results[0].response.body | fromjson[] | select(.Kind == "received" and .FrameType == "text") | .Body // "" | @base64d'
```
## Selecting requests
Give a `.posting.yaml` file to run one request, or a directory to run every request inside it,
+1
View File
@@ -8,6 +8,7 @@ require (
github.com/charmbracelet/x/ansi v0.11.3
github.com/darrenburns/terma v0.27.0
github.com/darrenburns/ultraviolet v0.0.0-20261012170537-2684fdbe5bed
github.com/gorilla/websocket v1.5.3
github.com/rivo/uniseg v0.4.7
github.com/theory/jsonpath v0.12.2
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78
+2 -2
View File
@@ -29,8 +29,6 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/darrenburns/terma v0.26.0 h1:m42epNNU997eVx1kU/rpNh3XhItWUPv7ibNEayq1UOA=
github.com/darrenburns/terma v0.26.0/go.mod h1:B0xSgLVxJTcL2P+AYusNmV4evp9EZtaCgcl0JMTyqxo=
github.com/darrenburns/terma v0.27.0 h1:sa40IKPPqaxPS/Hm8TzzSYfCSEiFN1duOJS+BegXGzI=
github.com/darrenburns/terma v0.27.0/go.mod h1:B0xSgLVxJTcL2P+AYusNmV4evp9EZtaCgcl0JMTyqxo=
github.com/darrenburns/ultraviolet v0.0.0-20261012170537-2684fdbe5bed h1:kNX1EcDAUqItAgjZ+kx26Lsq6NsiZBS8j2SVvefjfpk=
@@ -43,6 +41,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+5 -2
View File
@@ -9,8 +9,9 @@ import (
// ByKind sends each request with the sender for its kind.
type ByKind struct {
HTTP Sender // every kind carried over HTTP
GRPC Sender
HTTP Sender // every kind carried over HTTP
GRPC Sender
WebSocket Sender
}
func (k ByKind) Send(ctx context.Context, call Call) (*model.Response, error) {
@@ -29,6 +30,8 @@ func (k ByKind) For(kind *model.Kind) Sender {
return k.HTTP
case kind == model.GRPCKind:
return k.GRPC
case kind == model.WebSocketKind:
return k.WebSocket
}
return nil
}
+4 -1
View File
@@ -31,7 +31,7 @@ type Call struct {
// OnAuth reports OAuth progress and credential-storage warnings without
// credentials. Like OnTrace, it may be called from any goroutine.
OnAuth func(string)
// OnUpdate, when set, receives a gRPC or SSE response so far as its
// OnUpdate, when set, receives a streaming response so far as its
// messages arrive, before Send returns the whole of it. An SSE response
// lifts the request timeout after headers arrive when OnUpdate is set;
// the caller's context still cancels it. It may be called from any goroutine.
@@ -40,6 +40,9 @@ type Call struct {
// request's own messages, for more sent through it, and lifts the
// timeout once the call starts.
Stream *Stream
// Socket keeps a WebSocket open for interactive sends. Unlike a headless
// call, it doesn't send the draft automatically or time out after upgrade.
Socket *Socket
}
// Sender performs requests. Implementations must honour ctx cancellation:
+53
View File
@@ -30,6 +30,9 @@ func (f Fake) Send(ctx context.Context, call Call) (*model.Response, error) {
if call.Request.Kind() == model.GRPCKind {
return f.sendGRPC(ctx, call)
}
if call.Request.Kind() == model.WebSocketKind {
return f.sendWebSocket(ctx, call)
}
started := time.Now()
req, ok := model.Lower(call.Request)
if !ok {
@@ -262,3 +265,53 @@ func (f Fake) report(call Call, event model.TraceEvent) {
call.OnTrace(event)
}
}
func (f Fake) sendWebSocket(ctx context.Context, call Call) (*model.Response, error) {
req, err := model.Resolve(call.Request, call.Lookup)
if err != nil {
return nil, err
}
resp := &model.Response{StatusCode: 101, Reason: "Switching Protocols", Proto: "WebSocket", URL: req.URL,
Events: []model.MessageEvent{{At: time.Now(), Kind: model.MessageInfo, Title: "WebSocket connected"}}}
report := func() {
if call.OnUpdate != nil {
copy := *resp
copy.Events = append([]model.MessageEvent(nil), resp.Events...)
call.OnUpdate(&copy)
}
}
echo := func(m socketMessage) {
resp.Events = append(resp.Events, socketEvent(model.MessageSent, m), socketEvent(model.MessageReceived, m))
report()
}
if call.Socket != nil {
call.Socket.start(call)
defer call.Socket.Close()
report()
loop:
for {
select {
case m := <-call.Socket.queue:
if call.Socket.TakesMessages() {
echo(m)
}
case <-call.Socket.closed:
break loop
case <-ctx.Done():
break loop
}
}
} else if req.Payload.(model.WebSocket).Message != "" {
m, err := encodeSocketMessage(req.Payload.(model.WebSocket))
if err != nil {
return nil, err
}
echo(m)
}
resp.WebSocket = &model.WebSocketStatus{Code: 1000}
resp.Events = append(resp.Events, model.MessageEvent{At: time.Now(), Kind: model.MessageInfo, Title: "WebSocket disconnected: 1000"})
resp.Body, _ = json.Marshal(resp.Events)
resp.BodyContentType = "application/json"
resp.ReceivedAt = time.Now()
return resp, nil
}
+3 -3
View File
@@ -17,7 +17,7 @@ import (
// tests use.
func TestEveryKindHasASender(t *testing.T) {
http, grpc := NewHTTP("", TLSSettings{}), NewGRPC("", TLSSettings{}, "")
senders := ByKind{HTTP: http, GRPC: grpc}
senders := ByKind{HTTP: http, GRPC: grpc, WebSocket: &WebSocket{HTTP: http}}
for _, k := range model.Kinds {
t.Run(string(k.ID), func(t *testing.T) {
sender := senders.For(k)
@@ -42,13 +42,13 @@ func TestByKindRoutesEachKindToItsSender(t *testing.T) {
return &model.Response{}, nil
})
}
senders := ByKind{HTTP: record("http"), GRPC: record("grpc")}
senders := ByKind{HTTP: record("http"), GRPC: record("grpc"), WebSocket: record("websocket")}
for _, k := range model.Kinds {
if _, err := senders.Send(context.Background(), Call{Request: k.New()}); err != nil {
t.Fatal(err)
}
}
if want := []string{"http", "http", "grpc"}; !reflect.DeepEqual(got, want) {
if want := []string{"http", "http", "grpc", "websocket"}; !reflect.DeepEqual(got, want) {
t.Fatalf("sent to %v, want %v", got, want)
}
if _, err := (ByKind{HTTP: record("http")}).Send(context.Background(), Call{Request: model.GRPCKind.New()}); err == nil || !strings.Contains(err.Error(), "gRPC") {
+7 -3
View File
@@ -64,7 +64,7 @@ func (o *OAuth) entry(ctx context.Context, req model.Request) (string, *oauthEnt
// another environment's token. Neither keys nor values contain a secret
// outside the credential store.
origin := req.URL
if req.Kind().OverHTTP() {
if req.Kind().OverHTTP() || req.Kind() == model.WebSocketKind {
u, err := url.Parse(req.URL)
if err != nil {
return "", nil, errors.New("invalid OAuth resource URL")
@@ -112,8 +112,12 @@ func (o *OAuth) authorize(ctx context.Context, req model.Request, interactive bo
if err != nil {
return model.Auth{}, err
}
if req.Kind().OverHTTP() {
if err := secureOAuthURL(req.URL); err != nil {
if req.Kind().OverHTTP() || req.Kind() == model.WebSocketKind {
resource := req.URL
if req.Kind() == model.WebSocketKind {
resource = "http" + strings.TrimPrefix(resource, "ws")
}
if err := secureOAuthURL(resource); err != nil {
return model.Auth{}, errors.New("OAuth requests require HTTPS (HTTP is allowed only on loopback)")
}
} else {
+367
View File
@@ -0,0 +1,367 @@
package client
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"slices"
"sync"
"time"
"unicode/utf8"
"github.com/darrenburns/posting/v3/internal/model"
"github.com/gorilla/websocket"
)
const maxWebSocketMessageBytes = 1 << 20
// WebSocket shares HTTP's TLS settings, OAuth provider and cookie jar, but
// owns a separate, full-duplex connection. It never redirects or reconnects.
type WebSocket struct{ HTTP *HTTP }
type socketMessage struct {
typ int
body []byte
}
// Socket queues interactive messages without blocking the UI on network I/O.
// A Socket belongs to one connection and cannot be reopened.
type Socket struct {
mu sync.Mutex
parse func(model.WebSocket) (socketMessage, error)
ended bool
queue chan socketMessage
ready chan struct{}
closed chan struct{}
}
func NewSocket() *Socket {
return &Socket{queue: make(chan socketMessage, 32), ready: make(chan struct{}), closed: make(chan struct{})}
}
func (s *Socket) Ready() <-chan struct{} { return s.ready }
func (s *Socket) TakesMessages() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.parse != nil && !s.ended
}
func (s *Socket) Send(message model.WebSocket) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.ended || s.parse == nil {
return errors.New("the WebSocket is not connected")
}
m, err := s.parse(message)
if err != nil {
return err
}
select {
case s.queue <- m:
return nil
default:
return errors.New("the WebSocket send queue is full; wait before sending again")
}
}
// Close requests a normal close handshake. Unsent queued messages are discarded.
func (s *Socket) Close() {
s.mu.Lock()
defer s.mu.Unlock()
if !s.ended {
s.ended = true
close(s.closed)
}
}
func (s *Socket) start(call Call) {
s.mu.Lock()
defer s.mu.Unlock()
s.parse = func(message model.WebSocket) (socketMessage, error) {
if call.Request.Options.SubstituteBodyVariables {
if call.ResolveBody != nil {
text, err := call.ResolveBody(message.Message)
if err != nil {
return socketMessage{}, err
}
message.Message = text
} else {
message.Message = model.Substitute(message.Message, call.Lookup)
}
}
return encodeSocketMessage(message)
}
close(s.ready)
}
func encodeSocketMessage(message model.WebSocket) (socketMessage, error) {
// Bound the encoded input before allocating its decoded bytes, too.
if len(message.Message) > base64.StdEncoding.EncodedLen(maxWebSocketMessageBytes) {
return socketMessage{}, errors.New("WebSocket messages are limited to 1 MiB")
}
m := socketMessage{typ: websocket.TextMessage, body: []byte(message.Message)}
if message.Binary {
var err error
m.typ = websocket.BinaryMessage
m.body, err = base64.StdEncoding.DecodeString(message.Message)
if err != nil {
return m, fmt.Errorf("binary messages must be base64: %w", err)
}
} else if !utf8.Valid(m.body) {
return m, errors.New("text messages must be valid UTF-8")
}
if len(m.body) > maxWebSocketMessageBytes {
return m, errors.New("WebSocket messages are limited to 1 MiB")
}
return m, nil
}
func socketEvent(kind model.MessageEventKind, m socketMessage) model.MessageEvent {
e := model.MessageEvent{At: time.Now(), Kind: kind, Body: m.body, FrameType: "text", ContentType: "text/plain"}
if m.typ == websocket.BinaryMessage {
e.FrameType, e.ContentType = "binary", "application/octet-stream"
} else if json.Valid(m.body) {
e.ContentType = "application/json"
}
return e
}
func (w *WebSocket) Send(ctx context.Context, call Call) (*model.Response, error) {
if call.Socket != nil {
defer call.Socket.Close()
}
req, err := model.Resolve(call.Request, call.Lookup)
if err != nil {
return nil, err
}
message, ok := req.Payload.(model.WebSocket)
if !ok {
return nil, errors.New("not a WebSocket request")
}
target, err := url.Parse(req.URL)
if err != nil || target.Host == "" || target.Fragment != "" || (target.Scheme != "ws" && target.Scheme != "wss") {
return nil, errors.New("enter a ws:// or wss:// URL with a host and no fragment")
}
h := w.HTTP
h.init()
if h.tlsErr != nil {
return nil, h.tlsErr
}
req.Auth, err = h.OAuth.authorize(ctx, req, true, call.OnAuth)
if err != nil {
return nil, err
}
transport, err := h.transport(req)
if err != nil {
return nil, err
}
defer transport.CloseIdleConnections()
timeout := req.Options.TimeoutSeconds
if timeout <= 0 {
timeout = model.DefaultOptions().TimeoutSeconds
}
budget := time.Duration(timeout * float64(time.Second))
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
timer := time.AfterFunc(budget, func() { cancel(context.DeadlineExceeded) })
defer timer.Stop()
// Gorilla bounds upgrade I/O with a deadline, but doesn't interrupt a
// stalled upgrade on cancellation. Closing the raw socket does both, and
// also interrupts blocked reads/writes after the upgrade succeeds.
stopCancel := func() bool { return false }
defer func() { stopCancel() }()
dialer := websocket.Dialer{
TLSClientConfig: transport.TLSClientConfig, Proxy: transport.Proxy,
HandshakeTimeout: budget,
// Use the lifetime context, not the temporary handshake context.
NetDialContext: func(_ context.Context, network, address string) (net.Conn, error) {
conn, err := (&net.Dialer{}).DialContext(ctx, network, address)
if err == nil {
stopCancel = context.AfterFunc(ctx, func() { conn.Close() })
}
return conn, err
},
}
if req.Options.AttachCookies {
dialer.Jar = h.jar
}
headersIn := make(http.Header)
for _, header := range req.Headers {
if header.Enabled && header.Name != "" {
headersIn.Add(header.Name, header.Value)
}
}
if headersIn.Get("User-Agent") == "" && h.UserAgent != "" {
headersIn.Set("User-Agent", h.UserAgent)
}
switch req.Auth.Type {
case model.AuthBasic:
headersIn.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(req.Auth.Username+":"+req.Auth.Password)))
case model.AuthBearer:
headersIn.Set("Authorization", "Bearer "+req.Auth.Token)
}
trace := newTracer(call.OnTrace, target.Scheme == "wss")
started := time.Now()
trace.start(model.TraceConnect)
conn, resp, err := dialer.DialContext(ctx, req.URL, headersIn)
if err != nil {
trace.fail()
if resp == nil {
return nil, describeError(ctx, err, timeout)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxWebSocketMessageBytes))
return &model.Response{StatusCode: resp.StatusCode, Reason: reason(resp), Proto: resp.Proto,
Headers: headers(resp.Header), Body: body, URL: req.URL, Method: model.MethodGet,
StreamError: err.Error(), Trace: trace.events(), Elapsed: time.Since(started), ReceivedAt: time.Now()}, nil
}
defer conn.Close()
trace.complete(model.TraceConnect)
trace.start(model.TraceReceiveBody)
conn.SetReadLimit(maxWebSocketMessageBytes)
if call.Socket != nil {
timer.Stop()
call.Socket.start(call)
}
out := &model.Response{StatusCode: resp.StatusCode, Reason: reason(resp), Proto: "WebSocket",
Headers: headers(resp.Header), Cookies: cookies(resp.Cookies()), URL: req.URL, Method: model.MethodGet,
Events: []model.MessageEvent{{At: time.Now(), Kind: model.MessageInfo, Title: "WebSocket connected"}}}
report := func() {
if call.OnUpdate != nil {
snapshot := *out
snapshot.Events = slices.Clip(out.Events)
snapshot.Elapsed = time.Since(started)
snapshot.Trace = trace.events()
call.OnUpdate(&snapshot)
}
}
report()
// One reader processes control frames even while idle (the library
// replies to ping/close). Only this goroutine below owns the log/writer.
incoming := make(chan socketMessage)
readDone := make(chan error, 1)
readerCtx, stopReader := context.WithCancel(ctx)
defer stopReader()
go func() {
for {
typ, body, err := conn.ReadMessage()
if err != nil {
readDone <- err
return
}
if typ == websocket.TextMessage && !utf8.Valid(body) {
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseInvalidFramePayloadData, "invalid UTF-8"), time.Now().Add(time.Second))
readDone <- errors.New("received invalid UTF-8 in a text message")
return
}
select {
case incoming <- socketMessage{typ, body}:
case <-readerCtx.Done():
return
}
}
}()
logBytes := 0
record := func(kind model.MessageEventKind, m socketMessage) error {
if len(out.Events) >= maxLogEvents || logBytes+len(m.body) > maxBodyBytes {
return errors.New("WebSocket log limit reached (10,000 messages or 64 MiB)")
}
out.Events = append(out.Events, socketEvent(kind, m))
logBytes += len(m.body)
return nil
}
write := func(m socketMessage) error {
conn.SetWriteDeadline(time.Now().Add(budget))
if err := conn.WriteMessage(m.typ, m.body); err != nil {
return err
}
return record(model.MessageSent, m)
}
var outgoing <-chan socketMessage
var disconnect <-chan struct{}
if call.Socket != nil {
outgoing, disconnect = call.Socket.queue, call.Socket.closed
} else if message.Message != "" {
var m socketMessage
m, err = encodeSocketMessage(message)
if err == nil {
err = write(m)
}
}
ticker := time.NewTicker(updateInterval)
defer ticker.Stop()
dirty, localClose := len(out.Events) > 1, false
for err == nil {
select {
case m := <-incoming:
err = record(model.MessageReceived, m)
dirty = true
case m := <-outgoing:
// A queued send and Close can both be ready. Never start another
// write once the caller has requested disconnect.
if !call.Socket.TakesMessages() {
continue
}
err = write(m)
dirty = true
case err = <-readDone:
case <-ctx.Done():
err = context.Cause(ctx)
case <-disconnect:
localClose = true
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), time.Now().Add(time.Second))
// Bound the close handshake even when the peer doesn't reply.
conn.NetConn().SetReadDeadline(time.Now().Add(250 * time.Millisecond))
disconnect, outgoing = nil, nil
case <-ticker.C:
if dirty {
report()
dirty = false
}
}
}
closeStatus := &model.WebSocketStatus{Code: websocket.CloseAbnormalClosure}
var closeErr *websocket.CloseError
switch {
case localClose:
closeStatus.Code = websocket.CloseNormalClosure
case ctx.Err() != nil:
out.StreamError = describeError(ctx, err, timeout).Error()
case errors.As(err, &closeErr):
closeStatus.Code, closeStatus.Reason = closeErr.Code, closeErr.Text
if closeErr.Code != websocket.CloseNormalClosure && closeErr.Code != websocket.CloseGoingAway {
out.StreamError = err.Error()
}
default:
out.StreamError = describeError(ctx, err, timeout).Error()
}
out.WebSocket = closeStatus
title, kind := fmt.Sprintf("WebSocket disconnected: %d", closeStatus.Code), model.MessageInfo
if closeStatus.Reason != "" {
title += " · " + closeStatus.Reason
}
if out.StreamError != "" {
title, kind = "WebSocket interrupted: "+out.StreamError, model.MessageError
trace.fail()
} else {
trace.complete(model.TraceReceiveBody)
}
out.Events = append(out.Events, model.MessageEvent{At: time.Now(), Kind: kind, Title: title})
trace.start(model.TraceClosed)
conn.Close()
trace.complete(model.TraceClosed)
out.Elapsed, out.ReceivedAt, out.Trace = time.Since(started), time.Now(), trace.events()
// A portable transcript for headless output/export; event bodies are
// base64 in JSON so binary and text payloads both round-trip losslessly.
out.Body, _ = json.Marshal(out.Events)
out.BodyContentType = "application/json"
return out, nil
}
+251
View File
@@ -0,0 +1,251 @@
package client
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"github.com/darrenburns/posting/v3/internal/model"
"github.com/gorilla/websocket"
)
func socketRequest(server *httptest.Server) model.Request {
r := model.WebSocketKind.New()
r.URL = strings.Replace(server.URL, "http", "ws", 1)
return r
}
func TestWebSocketInteractiveExchange(t *testing.T) {
gotRequest := make(chan *http.Request, 1)
pong := make(chan string, 1)
closed := make(chan int, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotRequest <- r
upgrader := websocket.Upgrader{Subprotocols: []string{"events.v1"}, WriteBufferSize: 14}
conn, err := upgrader.Upgrade(w, r, http.Header{"Set-Cookie": {"socket=connected; Path=/"}})
if err != nil {
t.Error(err)
return
}
defer conn.Close()
conn.SetPongHandler(func(data string) error { pong <- data; return nil })
conn.WriteControl(websocket.PingMessage, []byte("alive"), time.Now().Add(time.Second))
// Fragmented messages must appear as one event, even across UTF-8 bytes.
writer, _ := conn.NextWriter(websocket.TextMessage)
writer.Write([]byte("{\"city\":\"Montr\xc3"))
writer.Write([]byte("\xa9al\"}"))
writer.Close()
for {
typ, body, err := conn.ReadMessage()
if err != nil {
var closeErr *websocket.CloseError
if errors.As(err, &closeErr) {
closed <- closeErr.Code
}
return
}
if err := conn.WriteMessage(typ, body); err != nil {
return
}
}
}))
defer server.Close()
req := socketRequest(server)
req.URL += "/:team"
req.PathParams = []model.KeyValue{{Name: "team", Value: "core", Enabled: true}}
req.Query = []model.KeyValue{{Name: "key", Value: "a&b", Enabled: true}}
req.Headers = []model.KeyValue{{Name: "Sec-WebSocket-Protocol", Value: "events.v1", Enabled: true}, {Name: "X-Ignore", Value: "no", Enabled: false}}
req.Auth = model.Auth{Type: model.AuthBearer, Token: "${TOKEN}"}
req.Payload = model.WebSocket{Message: "must not be sent on connect"}
req.Options.TimeoutSeconds = .2
h := NewHTTP("posting-test", TLSSettings{})
socket := NewSocket()
updates, done := make(chan *model.Response, 100), make(chan *model.Response, 1)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go func() {
resp, err := (&WebSocket{HTTP: h}).Send(ctx, Call{Request: req, Variables: map[string]string{"TOKEN": "test-token", "NAME": "Ada"}, Socket: socket, OnUpdate: func(r *model.Response) { updates <- r }})
if err != nil {
t.Error(err)
}
done <- resp
}()
await := func(n int) *model.Response {
t.Helper()
for {
select {
case resp := <-updates:
if len(resp.Events) >= n {
return resp
}
case <-ctx.Done():
t.Fatal("no live update")
}
}
}
first := await(2)
if first.Header("Sec-Websocket-Protocol") != "events.v1" || string(first.Events[1].Body) != `{"city":"Montréal"}` {
t.Fatalf("first response: %+v", first)
}
r := <-gotRequest
if r.URL.RequestURI() != "/core?key=a%26b" || r.Header.Get("Authorization") != "Bearer test-token" || r.Header.Get("X-Ignore") != "" || r.UserAgent() != "posting-test" {
t.Fatalf("handshake = %s %v", r.URL, r.Header)
}
select {
case got := <-pong:
if got != "alive" {
t.Fatal(got)
}
case <-ctx.Done():
t.Fatal("no pong while idle")
}
time.Sleep(250 * time.Millisecond) // The UI's connect timeout must now be lifted.
for i, message := range []model.WebSocket{{Message: `{"name":"${NAME}"}`}, {Message: "AP+A", Binary: true}, {}, {Binary: true}} {
if err := socket.Send(message); err != nil {
t.Fatal(err)
}
resp := await(4 + 2*i)
sent, received := resp.Events[2+2*i], resp.Events[3+2*i]
want, frame := []byte{}, "text"
switch i {
case 0:
want = []byte(`{"name":"Ada"}`)
case 1:
want, frame = []byte{0, 255, 128}, "binary"
case 3:
frame = "binary"
}
if string(sent.Body) != string(want) || string(received.Body) != string(want) || sent.Kind != model.MessageSent || received.Kind != model.MessageReceived || received.FrameType != frame {
t.Fatalf("exchange %d: %+v %+v", i, sent, received)
}
}
if err := socket.Send(model.WebSocket{Binary: true, Message: "not base64!"}); err == nil {
t.Fatal("invalid binary sent")
}
socket.Close()
var resp *model.Response
select {
case resp = <-done:
case <-ctx.Done():
t.Fatal("disconnect blocked")
}
if resp == nil || len(resp.Events) != 11 || resp.WebSocket.Code != 1000 || resp.StreamError != "" {
t.Fatalf("final = %+v", resp)
}
if <-closed != 1000 {
t.Fatal("disconnect didn't send a normal close")
}
if len(first.Events) != 2 || first.WebSocket != nil {
t.Fatal("a published snapshot was mutated")
}
if socket.TakesMessages() || socket.Send(model.WebSocket{}) == nil {
t.Fatal("closed socket takes messages")
}
var transcript []model.MessageEvent
if err := json.Unmarshal(resp.Body, &transcript); err != nil || !reflect.DeepEqual(transcript[5].Body, []byte{0, 255, 128}) {
t.Fatal("export lost binary bytes", err)
}
}
func TestWebSocketCloseTimeoutAndLimits(t *testing.T) {
for _, tc := range []struct {
name string
closeCode int
data []byte
wait bool
want string
}{
{"normal", 1000, []byte("hello"), false, ""},
{"policy", 1008, []byte("hello"), false, "policy"},
{"dropped", 0, []byte("partial"), false, "unexpected EOF"},
{"timeout", 0, []byte("partial"), true, "timed out"},
{"oversized", 0, []byte(strings.Repeat("x", maxWebSocketMessageBytes+1)), false, "read limit"},
{"invalid UTF-8", 0, []byte{255}, false, "invalid UTF-8"},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
conn.WriteMessage(websocket.TextMessage, tc.data)
if tc.closeCode != 0 {
conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(tc.closeCode, "policy"), time.Now().Add(time.Second))
}
if tc.wait {
conn.ReadMessage()
}
}))
defer server.Close()
req := socketRequest(server)
req.Options.TimeoutSeconds = .15
resp, err := (&WebSocket{HTTP: NewHTTP("", TLSSettings{})}).Send(context.Background(), Call{Request: req})
if err != nil {
t.Fatal(err)
}
if tc.want == "" && resp.StreamError != "" || tc.want != "" && !strings.Contains(resp.StreamError, tc.want) {
t.Fatalf("stream error = %q, want %q", resp.StreamError, tc.want)
}
if len(tc.data) < maxWebSocketMessageBytes && tc.name != "invalid UTF-8" && string(resp.Events[1].Body) != string(tc.data) {
t.Fatal("lost partial data")
}
if tc.closeCode != 0 && (resp.WebSocket.Code != tc.closeCode || resp.WebSocket.Reason != "policy") {
t.Fatal("lost close status")
}
})
}
}
func TestWebSocketHandshakeCancellationAndTLS(t *testing.T) {
started := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(started); <-r.Context().Done() }))
defer server.Close()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
_, err := (&WebSocket{HTTP: NewHTTP("", TLSSettings{})}).Send(ctx, Call{Request: socketRequest(server)})
done <- err
}()
<-started
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("cancelling a stalled upgrade did not close it")
}
tlsServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
_, body, err := conn.ReadMessage()
if err != nil {
return
}
conn.WriteMessage(websocket.TextMessage, body)
conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(1000, "finished"), time.Now().Add(time.Second))
}))
defer tlsServer.Close()
req := socketRequest(tlsServer)
req.Payload = model.WebSocket{Message: "headless"}
w := &WebSocket{HTTP: NewHTTP("", TLSSettings{})}
if _, err := w.Send(context.Background(), Call{Request: req}); err == nil {
t.Fatal("untrusted TLS accepted")
}
req.Options.VerifySSL = false
resp, err := w.Send(context.Background(), Call{Request: req})
if err != nil || resp.WebSocket.Code != 1000 || len(resp.Events) != 4 || string(resp.Events[2].Body) != "headless" {
t.Fatalf("headless TLS exchange = %+v, %v", resp, err)
}
}
+1
View File
@@ -32,6 +32,7 @@ type requestFile struct {
URL yamlString `yaml:"url,omitempty"`
GraphQL *graphQLFile `yaml:"graphql,omitempty"`
GRPC *grpcFile `yaml:"grpc,omitempty"`
WebSocket *webSocketFile `yaml:"websocket,omitempty"`
Body *bodyFile `yaml:"body,omitempty"`
Auth *authFile `yaml:"auth,omitempty"`
Headers []kvFile `yaml:"headers,omitempty"`
+23
View File
@@ -35,6 +35,16 @@ func (f *grpcFile) UnmarshalYAML(node *yaml.Node) error {
return decodeStrict(node, "grpc", (*plain)(f))
}
type webSocketFile struct {
Message yamlString `yaml:"message,omitempty"`
Binary bool `yaml:"binary,omitempty"`
}
func (f *webSocketFile) UnmarshalYAML(node *yaml.Node) error {
type plain webSocketFile
return decodeStrict(node, "websocket", (*plain)(f))
}
// protoSetFile is the `proto:` block of a gRPC request that names its schema
// files instead of asking the server for it.
type protoSetFile struct {
@@ -54,6 +64,16 @@ var kindBlocks = map[model.KindID]struct {
present func(requestFile) bool
decode func(requestFile) model.Payload
}{
model.KindWebSocket: {
key: "websocket",
present: func(in requestFile) bool { return in.WebSocket != nil },
decode: func(in requestFile) model.Payload {
if f := in.WebSocket; f != nil {
return model.WebSocket{Message: string(f.Message), Binary: f.Binary}
}
return model.WebSocket{}
},
},
model.KindGraphQL: {
key: "graphql",
present: func(in requestFile) bool { return in.GraphQL != nil },
@@ -171,6 +191,9 @@ func checkFields(in requestFile, kind *model.Kind) error {
func encodePayload(req model.Request, out *requestFile) error {
switch p := req.Payload.(type) {
case nil:
case model.WebSocket:
out.Kind = string(model.KindWebSocket)
out.WebSocket = &webSocketFile{Message: yamlString(p.Message), Binary: p.Binary}
case model.GraphQL:
out.Kind = string(model.KindGraphQL)
out.GraphQL = &graphQLFile{Query: yamlString(p.Query), Variables: yamlString(p.Variables), OperationName: yamlString(p.OperationName)}
+2 -2
View File
@@ -69,7 +69,7 @@ func TestAppendPersistsNextIDAndCompatibleEntries(t *testing.T) {
func TestEntriesThisBuildCantReadSurviveAnAppend(t *testing.T) {
store := ForCollection(t.TempDir(), "/c")
newer := `{"ID":2,"Request":{"Name":"Stream","Kind":"websocket","Payload":{"Channel":"chat"}},"Response":null,"SentAt":"2026-09-29T01:02:03Z"}`
newer := `{"ID":2,"Request":{"Name":"Stream","Kind":"future-protocol","Payload":{"Channel":"chat"}},"Response":null,"SentAt":"2026-09-29T01:02:03Z"}`
http, _ := json.Marshal(model.HistoryEntry{ID: 1, Request: model.NewRequest(), SentAt: time.Date(2026, 9, 29, 1, 0, 0, 0, time.UTC)})
if err := os.WriteFile(store.Path(), []byte(`{"version":1,"entries":[`+newer+`,`+string(http)+`]}`), 0o600); err != nil {
t.Fatal(err)
@@ -83,7 +83,7 @@ func TestEntriesThisBuildCantReadSurviveAnAppend(t *testing.T) {
}
data, _ := os.ReadFile(store.Path())
if !strings.Contains(string(data), newer) {
t.Fatalf("saving dropped or changed the websocket entry:\n%s", data)
t.Fatalf("saving dropped or changed the unknown entry:\n%s", data)
}
if again, err := store.Load(); err != nil || len(again) != 2 || again[0].ID != 3 || again[1].ID != 1 {
t.Fatalf("reloaded %+v, %v", again, err)
+2 -2
View File
@@ -41,13 +41,13 @@ func TestNormalizeClearsFieldsGRPCDoesNotUse(t *testing.T) {
}
}
func TestDigestAuthIsDroppedOnlyFromGRPC(t *testing.T) {
func TestDigestAuthIsDroppedFromUnsupportedKinds(t *testing.T) {
digest := Auth{Type: AuthDigest, Username: "ada", Password: "secret"}
for _, k := range Kinds {
r := k.Example()
r.Auth = digest
want := digest
if k == GRPCKind {
if k == GRPCKind || k == WebSocketKind {
want = Auth{Type: AuthNone}
}
if got := Normalize(r).Auth; got != want {
+13 -5
View File
@@ -10,9 +10,10 @@ import (
type KindID string
const (
KindHTTP KindID = "http"
KindGraphQL KindID = "graphql"
KindGRPC KindID = "grpc"
KindHTTP KindID = "http"
KindGraphQL KindID = "graphql"
KindGRPC KindID = "grpc"
KindWebSocket KindID = "websocket"
)
// Fields is a set of the shared Request fields a kind uses. Normalize resets
@@ -50,7 +51,7 @@ func (f Fields) Has(field Fields) bool { return f&field == field }
type Kind struct {
ID KindID
Label string // "HTTP", "GraphQL", "gRPC"
// Badge is the three letter label lists show. HTTP shows the method instead.
// Badge is the short label lists show (at most three letters). HTTP shows the method instead.
Badge string
Fields Fields
@@ -82,12 +83,19 @@ var (
decode: decodePayload[GRPC],
example: exampleGRPC,
}
WebSocketKind = &Kind{
ID: KindWebSocket, Label: "WebSocket", Badge: "WS",
Fields: FieldHeaders | FieldQuery | FieldPathParams | FieldAuth | FieldCookies | FieldProxy,
zero: WebSocket{},
decode: decodePayload[WebSocket],
example: exampleWebSocket,
}
)
// Kinds lists every kind in menu and sort order. Each layer that keeps a
// table keyed by kind has a test ranging over this list, so a new kind added
// here turns those tests red until every layer knows about it.
var Kinds = []*Kind{HTTPKind, GraphQLKind, GRPCKind}
var Kinds = []*Kind{HTTPKind, GraphQLKind, GRPCKind, WebSocketKind}
// KindByID finds a kind by its file name.
func KindByID(id KindID) (*Kind, bool) {
+2 -2
View File
@@ -21,8 +21,8 @@ func TestKindsAreComplete(t *testing.T) {
if found, ok := KindByID(k.ID); !ok || found != k {
t.Fatalf("KindByID(%q) = %v, %v", k.ID, found, ok)
}
if k != HTTPKind && len(k.Badge) != 3 {
t.Errorf("badge %q is not three letters", k.Badge)
if k != HTTPKind && (len(k.Badge) == 0 || len(k.Badge) > 3) {
t.Errorf("badge %q must fit within three letters", k.Badge)
}
if got := k.New().Kind(); got != k {
t.Errorf("New().Kind() = %s", got.ID)
+7 -3
View File
@@ -92,6 +92,9 @@ type MessageEvent struct {
// to "message" for SSE; both fields are empty for other protocols.
Event string `json:",omitempty"`
ID string `json:",omitempty"`
// FrameType distinguishes WebSocket text and binary messages, including
// empty messages. Body always retains the original payload bytes.
FrameType string `json:",omitempty"`
}
type MessageEventKind string
@@ -119,13 +122,14 @@ type Response struct {
// Events is the chronological log of a streaming exchange. Unary calls
// and older history entries have none and use the body viewer instead.
Events []MessageEvent `json:",omitempty"`
// StreamError explains an interrupted SSE response. The HTTP status,
// StreamError explains an interrupted stream. The HTTP status,
// received body and completed events remain available for inspection.
StreamError string `json:",omitempty"`
// BodyContentType is the media type of Body when Body isn't what came
// over the wire, as when gRPC messages are decoded to JSON.
BodyContentType string `json:",omitempty"`
GRPC *GRPCStatus `json:",omitempty"`
BodyContentType string `json:",omitempty"`
GRPC *GRPCStatus `json:",omitempty"`
WebSocket *WebSocketStatus `json:",omitempty"`
Elapsed time.Duration
// ReceivedAt is when the response finished arriving.
ReceivedAt time.Time
+55
View File
@@ -0,0 +1,55 @@
package model
import "strconv"
// WebSocket is a draft message. Connecting in the UI does not send it;
// subsequent sends use its current contents. Binary messages are base64.
type WebSocket struct {
Message string
Binary bool
}
func (w WebSocket) Kind() *Kind { return WebSocketKind }
func (w WebSocket) clone() Payload { return w }
func (w WebSocket) size() int { return len(w.Message) }
func (w WebSocket) label() string { return "" }
func (w WebSocket) resolve(_, all func(string) string, opts Options) Payload {
if opts.SubstituteBodyVariables {
w.Message = all(w.Message)
}
return w
}
func (w WebSocket) status(resp *Response) Status {
if resp.WebSocket == nil {
return httpStatus(resp)
}
close := resp.WebSocket
class := StatusClassSuccess
if close.Code != 1000 && close.Code != 1001 {
class = StatusClassError
}
text := "Disconnected"
if close.Reason != "" {
text = close.Reason
}
return Status{Code: strconv.Itoa(close.Code), Text: text, Class: class}
}
// WebSocketStatus records the close code and reason, not the HTTP upgrade.
type WebSocketStatus struct {
Code int
Reason string
}
func exampleWebSocket() Request {
r := NewRequest()
r.Name, r.URL = "Deployment updates", "wss://api.example.com/events/:team"
r.Payload = WebSocket{Message: `{"subscribe":"${USER_ID}"}`}
r.Headers = []KeyValue{{Name: "Sec-WebSocket-Protocol", Value: "events.v1", Enabled: true}}
r.PathParams = []KeyValue{{Name: "team", Value: "core", Enabled: true}}
r.Query = []KeyValue{{Name: "region", Value: "eu", Enabled: false}}
r.Auth = Auth{Type: AuthBearer, Token: "${API_TOKEN}"}
r.File = "events.posting.yaml"
return r
}
+12 -1
View File
@@ -26,7 +26,7 @@ func (a *App) actions() []action {
{id: "send-request", keys: []string{"ctrl+j", "alt+enter"}, name: "Send", help: "Send the request", run: a.send},
{id: "jump", keys: []string{t.DefaultJumpKey}, name: "Jump", help: "Jump mode: move focus by typing a label", hidden: true, run: a.jump.Activate},
{id: "search-requests", keys: []string{"ctrl+g"}, name: "Search requests", help: "Search the collection", hidden: true, run: a.focusTreeSearch},
{id: "stream", keys: []string{"alt+j"}, name: a.streamActionName(), help: "Open a gRPC stream, or finish sending messages", hidden: !a.isGRPC(), run: a.toggleStream},
{id: "stream", keys: []string{"alt+j"}, name: a.streamActionName(), help: "Toggle a gRPC stream or WebSocket connection", hidden: !a.isGRPC() && !a.isWebSocket(), run: a.toggleStream},
{id: "save-request", keys: []string{"ctrl+s"}, name: "Save", help: "Save the request to the collection", run: a.saveRequest},
{id: "new-request", keys: []string{"ctrl+n"}, name: "New tab", help: "Open a new request tab", run: a.newTab},
{id: "commands", keys: []string{"ctrl+p"}, name: "Commands", help: "Command palette", run: a.openPalette},
@@ -51,8 +51,19 @@ func (a *App) isGRPC() bool {
return s != nil && s.kind.Peek() == model.KindGRPC
}
func (a *App) isWebSocket() bool {
s := a.current()
return s != nil && s.kind.Peek() == model.KindWebSocket
}
// streamActionName is the stream action's footer label.
func (a *App) streamActionName() string {
if a.isWebSocket() {
if a.current().phase.Peek() == exchangeSending {
return "Disconnect"
}
return "Connect"
}
if s := a.current(); s != nil && s.streaming.Peek() == streamOpen {
return "Finish sending"
}
+25 -1
View File
@@ -593,7 +593,13 @@ func (a *App) send() {
}
// A stream stays open when the request becomes another kind, until the
// next send cancels it.
if s.streaming.Peek() == streamOpen && s.kind.Peek() == model.KindGRPC {
if s.kind.Peek() == model.KindWebSocket && s.socket != nil {
if err := s.socket.Send(s.payloads[model.KindWebSocket].payload().(model.WebSocket)); err != nil {
a.notify(err.Error(), toastWarning)
}
return
}
if s.streaming.Peek() == streamOpen && s.kind.Peek() == model.KindGRPC && s.stream != nil {
message := s.payloads[model.KindGRPC].(*grpcEditor).message.GetText()
if err := s.stream.Send(message); err != nil {
a.notify(err.Error(), toastWarning)
@@ -609,8 +615,16 @@ func (a *App) toggleStream() {
s := a.current()
switch {
case s == nil:
case s.kind.Peek() == model.KindWebSocket:
if s.phase.Peek() == exchangeSending && s.socket != nil {
s.Stop()
} else {
a.start(s, nil)
}
case s.kind.Peek() != model.KindGRPC:
a.notify("Only gRPC requests can open a stream", toastWarning)
case s.socket != nil:
a.start(s, client.NewStream())
case s.streaming.Peek() == streamStarting:
a.notify("The stream hasn't started yet", toastWarning)
case s.streaming.Peek() == streamOpen:
@@ -626,6 +640,13 @@ func (a *App) toggleStream() {
// the one open. It lives outside grpcCommands, which kindViews holds, since
// opening a stream reads kindViews.
func (a *App) streamCommand(s *Session) t.CommandPaletteItem {
if s.kind.Peek() == model.KindWebSocket {
label := "Connect WebSocket"
if s.phase.Peek() == exchangeSending && s.socket != nil {
label = "Disconnect WebSocket"
}
return t.CommandPaletteItem{Label: label, Description: "Connect without sending the draft, or disconnect and keep the message log", Action: a.run(a.toggleStream)}
}
if s.streaming.Peek() == streamOpen {
return t.CommandPaletteItem{Label: "Finish sending gRPC messages", Description: "Stop sending, and wait for the server to end the call", Action: a.run(a.toggleStream)}
}
@@ -654,6 +675,9 @@ func (a *App) start(s *Session, stream *client.Stream) {
return
}
call := client.Call{Request: req, Variables: model.Values(resolved), ResolveBody: variables.Substitute, Stream: stream}
if req.Kind() == model.WebSocketKind {
call.Socket = client.NewSocket()
}
s.Send(a.sender, call, func(req model.Request, resp *model.Response, status model.Status) {
a.captureResponse(req, resp)
if req.Kind() == model.GRPCKind {
+5 -1
View File
@@ -127,7 +127,11 @@ func (a *App) exportCommand(resolve bool) (string, error) {
}
req = resolved
}
return kindViews[req.Kind().ID].export.format(a, req)
format := kindViews[req.Kind().ID].export.format
if format == nil {
return "", errors.New("this request kind can only be exported as YAML")
}
return format(a, req)
}
// copyExport copies the request as a command and shows it.
+77
View File
@@ -0,0 +1,77 @@
package ui
import (
"github.com/darrenburns/posting/v3/internal/model"
t "github.com/darrenburns/terma"
)
const webSocketMessageID = "req-ws-message"
type webSocketEditor struct {
session *Session
message *t.TextAreaState
scroll *t.ScrollState
binary *t.CheckboxState
variables *completion
}
func newWebSocketEditor(s *Session) payloadEditor {
return &webSocketEditor{session: s, message: t.NewTextAreaState(""), scroll: t.NewScrollState(), binary: t.NewCheckboxState(false), variables: newCompletion()}
}
func (e *webSocketEditor) load(req model.Request) {
w, _ := req.Payload.(model.WebSocket)
e.message.SetText(w.Message)
e.message.ClearSelection()
e.message.CursorIndex.Set(0)
e.scroll.SetOffset(0)
e.binary.Checked.Set(w.Binary)
}
func (e *webSocketEditor) payload() model.Payload {
return model.WebSocket{Message: e.message.GetText(), Binary: e.binary.Checked.Get()}
}
func (e *webSocketEditor) tabs() []requestTab {
return []requestTab{{key: "ws-message", label: "Message", jump: "i", marked: func() bool { return hasText(e.message) }}}
}
func (e *webSocketEditor) focusID(string) string { return webSocketMessageID }
func (e *webSocketEditor) view(_ string, a *App) t.Widget {
return webSocketMessageView{app: a, editor: e}
}
type webSocketMessageView struct {
fillParent
app *App
editor *webSocketEditor
}
func (v webSocketMessageView) Build(ctx t.BuildContext) t.Widget {
theme := ctx.Theme()
e, s, a := v.editor, v.editor.session, v.app
substitute := s.substitute.Checked.Get()
language, placeholder := "json", "Write a text or JSON message…"
if e.binary.Checked.Get() {
language, placeholder = "text", "Base64-encoded binary message…"
}
var area t.Widget = t.TextArea{
ID: webSocketMessageID, State: e.message, ScrollState: e.scroll, Placeholder: placeholder,
Highlighter: a.bodyHighlighter(theme, language, substitute),
Style: t.Style{Width: t.Flex(1), BackgroundColor: theme.Background, Padding: inset},
OnChange: func(string) { s.touch() },
ExtraKeybinds: a.externalKeybinds(e.message, language, s.touch),
}
if substitute {
area = e.variables.wrap(theme, area, a.variableChoices(), t.Flex(1))
}
label := "Connect first; the draft is not sent on connection"
if s.streaming.Get() == streamOpen {
label = a.keyHint("send-request") + " Send message · " + a.keyHint("stream") + " Disconnect"
}
return t.Column{Style: t.Style{Width: t.Flex(1), Height: t.Flex(1)}, Spacing: 1, Children: []t.Widget{
&t.Checkbox{ID: "req-ws-binary", State: e.binary, Label: "Binary (base64)", OnChange: func(bool) { s.touch() }, Style: t.Style{Padding: inset}},
scrollingArea(webSocketMessageID, e.scroll, theme.Background, area),
t.Text{Content: label, Style: t.Style{ForegroundColor: theme.TextMuted, Padding: inset}},
}}
}
+9
View File
@@ -60,6 +60,15 @@ var kindViews = map[model.KindID]kindView{
responseTabs: httpResponseTabs,
export: curlExporter,
},
model.KindWebSocket: {
hotkey: "w",
color: func(theme t.ThemeData) t.Color {
return unlikeMethods(theme, nil, theme.Link, theme.AccentText, theme.SecondaryText)
},
urlPlaceholder: "Enter a WebSocket URL, e.g. wss://api.example.com/events",
newEditor: newWebSocketEditor,
responseTabs: httpResponseTabs,
},
model.KindGraphQL: {
hotkey: "q",
color: graphQLColor,
+5 -1
View File
@@ -2,6 +2,7 @@ package ui
import (
"regexp"
"slices"
"strings"
"github.com/charmbracelet/x/ansi"
@@ -130,7 +131,7 @@ func (a *App) paletteItems() []t.CommandPaletteItem {
if s.authType.Peek() == model.AuthOAuth2 && a.oauth != nil {
items = append(items, t.CommandPaletteItem{Label: "Forget OAuth token", Description: "Clear cached credentials; the next send authorizes again", Action: a.run(a.forgetOAuth)})
}
if s.kind.Peek() == model.KindGRPC {
if s.kind.Peek() == model.KindGRPC || s.kind.Peek() == model.KindWebSocket {
items = append(items, a.streamCommand(s))
}
if commands := kindViews[s.kind.Peek()].commands; commands != nil {
@@ -158,6 +159,9 @@ func (a *App) paletteItems() []t.CommandPaletteItem {
{Label: "View: expand request", Action: a.run(func() { a.expanded.Set("request") })},
{Label: "View: expand response", Action: a.run(func() { a.expanded.Set("response") })},
}...)
if a.isWebSocket() {
items = slices.DeleteFunc(items, func(item t.CommandPaletteItem) bool { return item.Label == "Export as "+a.exportTool() })
}
if a.expanded.Peek() != "" {
items = append(items, t.CommandPaletteItem{Label: "View: restore panels", Hint: "alt+z", Action: a.run(func() { a.expanded.Set("") })})
}
+22 -1
View File
@@ -1,6 +1,7 @@
package ui
import (
"encoding/base64"
"fmt"
"strings"
@@ -56,6 +57,11 @@ func (m *messageLog) update(s *Session, events []model.MessageEvent, reset bool)
if preview == "" {
preview = string(event.Body[:min(len(event.Body), 256)])
}
if event.FrameType == "binary" {
preview = "binary · " + model.FormatBytes(len(event.Body)) + " · " + base64.StdEncoding.EncodeToString(event.Body[:min(len(event.Body), 96)])
} else if event.FrameType == "text" && len(event.Body) == 0 {
preview = "text · empty message"
}
if event.Event != "" {
preview = event.Event + " · " + preview
}
@@ -97,7 +103,10 @@ func (m *messageLog) showSelected(s *Session) {
func messageResponse(event model.MessageEvent) *model.Response {
body, contentType := event.Body, event.ContentType
if len(body) == 0 && event.Event == "" {
if event.FrameType == "binary" {
body, contentType = []byte(base64.StdEncoding.EncodeToString(body)), "text/plain"
}
if len(body) == 0 && event.Event == "" && event.FrameType == "" {
body, contentType = []byte(event.Title), "text/plain"
}
return &model.Response{Body: body, BodyContentType: contentType}
@@ -201,6 +210,8 @@ func (v responseMessages) Build(ctx t.BuildContext) t.Widget {
hint = "esc Back to log"
} else if m.narrow.Get() && s.phase.Get() == exchangeSending && s.response.Get().ContentType() == "text/event-stream" {
hint = "enter Inspect · esc Stop"
} else if m.narrow.Get() && s.phase.Get() == exchangeSending && s.sent.Kind() == model.WebSocketKind {
hint = "enter Inspect · esc Disconnect"
}
return messageLogLayout{
state: m,
@@ -255,7 +266,17 @@ func (v messageInspector) Build(ctx t.BuildContext) t.Widget {
case model.MessageReceived:
label += " Received"
}
if row.event.FrameType != "" {
label += " · " + row.event.FrameType
if row.event.FrameType == "binary" {
label += " (base64)"
}
}
var body t.Widget = responseBody{app: v.app, session: v.session, response: messageResponse(row.event)}
if row.event.FrameType != "" && len(row.event.Body) == 0 {
body = t.Scrollable{ID: "resp-body", State: v.session.responseBodyScroll, Focusable: true,
Style: t.Style{Width: t.Flex(1), Height: t.Flex(1)}, Child: emptyState{Title: "Empty message"}}
}
if row.event.Event != "" {
metadata := row.event.Event
if row.event.ID != "" {
+3
View File
@@ -160,6 +160,9 @@ func (p responsePanel) streamHints(streaming streamPhase) string {
return "[$TextMuted][b]" + escapeMarkup(key) + "[/] " + what + "[/]"
}
var hints []string
if p.session.sent.Kind() == model.WebSocketKind {
return hint(p.app.keyHint("send-request"), "sends the message") + "[$TextMuted] · [/]" + hint("esc", "disconnects")
}
if streaming == streamOpen {
hints = append(hints, hint(p.app.keyHint("send-request"), "sends the message"), hint(p.app.keyHint("stream"), "finishes sending"))
}
+19 -2
View File
@@ -163,6 +163,7 @@ type Session struct {
live bool
// stream is the open stream of the exchange in flight, if it has one.
stream *client.Stream
socket *client.Socket
streaming t.Signal[streamPhase]
// dispatch schedules exchange updates on the UI goroutine.
dispatch func(func())
@@ -603,6 +604,7 @@ func (s *Session) Send(sender client.Sender, call client.Call, onDone func(model
s.cancel = cancel
s.live = false
s.stream = stream
s.socket = call.Socket
s.phase.Set(exchangeSending)
s.err.Set(nil)
s.trace.Set(nil)
@@ -644,18 +646,27 @@ func (s *Session) Send(sender client.Sender, call client.Call, onDone func(model
}
})
}
var sending interface {
Ready() <-chan struct{}
TakesMessages() bool
}
if stream != nil {
sending = stream
} else if call.Socket != nil {
sending = call.Socket
}
if sending != nil {
s.streaming.Set(streamStarting)
go func() {
select {
case <-stream.Ready():
case <-sending.Ready():
case <-ctx.Done():
return
}
s.dispatch(func() {
if inFlight() && s.streaming.Peek() == streamStarting {
s.streaming.Set(streamEnded)
if stream.TakesMessages() {
if sending.TakesMessages() {
s.streaming.Set(streamOpen)
s.streamShimmer.Start()
}
@@ -725,6 +736,11 @@ func (s *Session) Cancel() {
// Stop cancels the in-flight exchange but, unlike Cancel, still shows what
// it returns, so stopping a stream keeps the messages that arrived.
func (s *Session) Stop() {
if s.socket != nil && s.socket.TakesMessages() {
s.socket.Close()
s.streaming.Set(streamEnded)
return
}
if s.cancel != nil {
s.cancel()
}
@@ -733,6 +749,7 @@ func (s *Session) Stop() {
func (s *Session) endStream() {
s.streamShimmer.Stop()
s.stream = nil
s.socket = nil
s.streaming.Set(streamNone)
}
File diff suppressed because one or more lines are too long
+52 -48
View File
@@ -632,8 +632,8 @@
<rect x="402.8" y="86.4" width="8.4" height="19.6" fill="#FF69B4"/>
<rect x="411.2" y="86.4" width="8.4" height="19.6" fill="#FF69B4"/>
<rect x="419.6" y="86.4" width="8.4" height="19.6" fill="#FF69B4"/>
<rect x="428.0" y="86.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="436.4" y="86.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="428.0" y="86.4" width="8.4" height="19.6" fill="#FF69B4"/>
<rect x="436.4" y="86.4" width="8.4" height="19.6" fill="#FF69B4"/>
<rect x="444.8" y="86.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="453.2" y="86.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="461.6" y="86.4" width="8.4" height="19.6" fill="#0F0F1F"/>
@@ -714,8 +714,8 @@
<rect x="1091.6" y="86.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="310.40" y="86.40" width="1.05" height="19.60" fill="#5F4795"/>
<text x="335.6" y="96.2" fill="#0F0F1F">GET</text>
<text x="411.2" y="96.2" fill="#0A0407">g</text>
<text x="428.0" y="96.2" class="bold" fill="#FAAFD5">ASE_URL</text>
<text x="428.0" y="96.2" fill="#0A0407">g</text>
<text x="444.8" y="96.2" class="bold" fill="#FAAFD5">E_URL</text>
<text x="495.2" y="96.2" fill="#9A96BE">=</text>
<text x="512.0" y="96.2" fill="#F5F5F6">http://localhost:8000</text>
<text x="705.2" y="96.2" class="italic" fill="#9A96BE">from</text>
@@ -770,8 +770,8 @@
<rect x="402.8" y="106.0" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="411.2" y="106.0" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="419.6" y="106.0" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="428.0" y="106.0" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="436.4" y="106.0" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="428.0" y="106.0" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="436.4" y="106.0" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="444.8" y="106.0" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="453.2" y="106.0" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="461.6" y="106.0" width="8.4" height="19.6" fill="#0F0F1F"/>
@@ -856,9 +856,9 @@
<text x="75.2" y="115.8" class="bold" fill="#9A96BE">/</text>
<rect x="310.40" y="106.00" width="1.05" height="19.60" fill="#5E4694"/>
<text x="335.6" y="115.8" fill="#F5F5F6">POST</text>
<text x="411.2" y="115.8" fill="#9A96BE">p</text>
<text x="428.0" y="115.8" class="bold" fill="#F5F5F6">ables</text>
<path d="M 428.00 124.10 H 470.00" fill="none" stroke="#FF69B4" stroke-width="1.00"/>
<text x="428.0" y="115.8" fill="#9A96BE">p</text>
<text x="444.8" y="115.8" class="bold" fill="#F5F5F6">les</text>
<path d="M 444.80 124.10 H 470.00" fill="none" stroke="#FF69B4" stroke-width="1.00"/>
<text x="470.0" y="115.8" fill="#FAAFD5">•</text>
<text x="495.2" y="115.8" fill="#9A96BE">Headers</text>
<text x="562.4" y="115.8" fill="#9A96BE">1</text>
@@ -919,8 +919,8 @@
<rect x="402.8" y="125.6" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="411.2" y="125.6" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="419.6" y="125.6" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="428.0" y="125.6" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="436.4" y="125.6" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="428.0" y="125.6" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="436.4" y="125.6" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="444.8" y="125.6" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="453.2" y="125.6" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="461.6" y="125.6" width="8.4" height="19.6" fill="#0F0F1F"/>
@@ -1013,7 +1013,7 @@
<text x="184.4" y="135.4" fill="#F5F5F6">r</text>
<rect x="310.40" y="125.60" width="1.05" height="19.60" fill="#5D4693"/>
<text x="335.6" y="135.4" fill="#F5F5F6">PUT</text>
<text x="411.2" y="135.4" fill="#9A96BE">u</text>
<text x="428.0" y="135.4" fill="#9A96BE">u</text>
<rect x="8.0" y="145.2" width="8.4" height="19.6" fill="#2D1D45"/>
<rect x="16.4" y="145.2" width="8.4" height="19.6" fill="#2D1D45"/>
<rect x="24.8" y="145.2" width="8.4" height="19.6" fill="#2C1D44"/>
@@ -1064,8 +1064,8 @@
<rect x="402.8" y="145.2" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="411.2" y="145.2" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="419.6" y="145.2" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="428.0" y="145.2" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="436.4" y="145.2" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="428.0" y="145.2" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="436.4" y="145.2" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="444.8" y="145.2" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="453.2" y="145.2" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="461.6" y="145.2" width="8.4" height="19.6" fill="#0F0F1F"/>
@@ -1153,7 +1153,7 @@
<text x="108.8" y="155.0" fill="#F5F5F6">gin</text>
<rect x="310.40" y="145.20" width="1.05" height="19.60" fill="#5B4691"/>
<text x="335.6" y="155.0" fill="#F5F5F6">PATCH</text>
<text x="411.2" y="155.0" fill="#9A96BE">a</text>
<text x="428.0" y="155.0" fill="#9A96BE">a</text>
<rect x="8.0" y="164.8" width="8.4" height="19.6" fill="#2C1D44"/>
<rect x="16.4" y="164.8" width="8.4" height="19.6" fill="#2C1D44"/>
<rect x="24.8" y="164.8" width="8.4" height="19.6" fill="#2C1D43"/>
@@ -1204,8 +1204,8 @@
<rect x="402.8" y="164.8" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="411.2" y="164.8" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="419.6" y="164.8" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="428.0" y="164.8" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="436.4" y="164.8" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="428.0" y="164.8" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="436.4" y="164.8" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="444.8" y="164.8" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="453.2" y="164.8" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="461.6" y="164.8" width="8.4" height="19.6" fill="#0F0F1F"/>
@@ -1291,9 +1291,9 @@
<text x="75.2" y="174.6" class="bold" fill="#9A96BE">s/</text>
<rect x="310.40" y="164.80" width="1.05" height="19.60" fill="#5A4690"/>
<text x="335.6" y="174.6" fill="#F5F5F6">DELETE</text>
<text x="411.2" y="174.6" fill="#9A96BE">d</text>
<text x="428.0" y="174.6" fill="#FA9D7B">USER_ID}</text>
<path d="M 428.00 182.90 C 429.50 182.90 429.50 181.40 431.00 181.40 C 432.50 181.40 432.50 182.90 434.00 182.90 C 435.50 182.90 435.50 181.40 437.00 181.40 C 438.50 181.40 438.50 182.90 440.00 182.90 C 441.50 182.90 441.50 181.40 443.00 181.40 C 444.50 181.40 444.50 182.90 446.00 182.90 C 447.50 182.90 447.50 181.40 449.00 181.40 C 450.50 181.40 450.50 182.90 452.00 182.90 C 453.50 182.90 453.50 181.40 455.00 181.40 C 456.50 181.40 456.50 182.90 458.00 182.90 C 459.50 182.90 459.50 181.40 461.00 181.40 C 462.50 181.40 462.50 182.90 464.00 182.90 C 465.50 182.90 465.50 181.40 467.00 181.40 C 468.50 181.40 468.50 182.90 470.00 182.90 C 471.50 182.90 471.50 181.40 473.00 181.40 C 474.50 181.40 474.50 182.90 476.00 182.90 C 477.50 182.90 477.50 181.40 479.00 181.40 C 480.50 181.40 480.50 182.90 482.00 182.90 C 483.50 182.90 483.50 181.40 485.00 181.40 C 486.50 181.40 486.50 182.90 488.00 182.90 C 489.50 182.90 489.50 181.40 491.00 181.40 C 492.50 181.40 492.50 182.90 494.00 182.90 C 494.60 182.90 494.60 181.40 495.20 181.40" fill="none" stroke="#FF4500" stroke-width="1.00"/>
<text x="428.0" y="174.6" fill="#9A96BE">d</text>
<text x="444.8" y="174.6" fill="#FA9D7B">ER_ID}</text>
<path d="M 444.80 182.90 C 446.30 182.90 446.30 181.40 447.80 181.40 C 449.30 181.40 449.30 182.90 450.80 182.90 C 452.30 182.90 452.30 181.40 453.80 181.40 C 455.30 181.40 455.30 182.90 456.80 182.90 C 458.30 182.90 458.30 181.40 459.80 181.40 C 461.30 181.40 461.30 182.90 462.80 182.90 C 464.30 182.90 464.30 181.40 465.80 181.40 C 467.30 181.40 467.30 182.90 468.80 182.90 C 470.30 182.90 470.30 181.40 471.80 181.40 C 473.30 181.40 473.30 182.90 474.80 182.90 C 476.30 182.90 476.30 181.40 477.80 181.40 C 479.30 181.40 479.30 182.90 480.80 182.90 C 482.30 182.90 482.30 181.40 483.80 181.40 C 485.30 181.40 485.30 182.90 486.80 182.90 C 488.30 182.90 488.30 181.40 489.80 181.40 C 491.30 181.40 491.30 182.90 492.80 182.90 C 494.00 182.90 494.00 181.40 495.20 181.40" fill="none" stroke="#FF4500" stroke-width="1.00"/>
<text x="495.2" y="174.6" fill="#7BF8C8">&#34;</text>
<rect x="8.0" y="184.4" width="8.4" height="19.6" fill="#2B1D43"/>
<rect x="16.4" y="184.4" width="8.4" height="19.6" fill="#2B1D43"/>
@@ -1345,8 +1345,8 @@
<rect x="402.8" y="184.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="411.2" y="184.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="419.6" y="184.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="428.0" y="184.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="436.4" y="184.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="428.0" y="184.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="436.4" y="184.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="444.8" y="184.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="453.2" y="184.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="461.6" y="184.4" width="8.4" height="19.6" fill="#0F0F1F"/>
@@ -1438,7 +1438,7 @@
<text x="150.8" y="194.2" fill="#F5F5F6">r</text>
<rect x="310.40" y="184.40" width="1.05" height="19.60" fill="#58468E"/>
<text x="335.6" y="194.2" fill="#F5F5F6">HEAD</text>
<text x="411.2" y="194.2" fill="#9A96BE">h</text>
<text x="428.0" y="194.2" fill="#9A96BE">h</text>
<rect x="8.0" y="204.0" width="8.4" height="19.6" fill="#2B1D42"/>
<rect x="16.4" y="204.0" width="8.4" height="19.6" fill="#2A1D42"/>
<rect x="24.8" y="204.0" width="8.4" height="19.6" fill="#2A1D41"/>
@@ -1489,8 +1489,8 @@
<rect x="402.8" y="204.0" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="411.2" y="204.0" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="419.6" y="204.0" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="428.0" y="204.0" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="436.4" y="204.0" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="428.0" y="204.0" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="436.4" y="204.0" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="444.8" y="204.0" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="453.2" y="204.0" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="461.6" y="204.0" width="8.4" height="19.6" fill="#0F0F1F"/>
@@ -1582,7 +1582,7 @@
<text x="167.6" y="213.8" fill="#F5F5F6">s</text>
<rect x="310.40" y="204.00" width="1.05" height="19.60" fill="#57468D"/>
<text x="335.6" y="213.8" fill="#F5F5F6">OPTIONS</text>
<text x="411.2" y="213.8" fill="#9A96BE">o</text>
<text x="428.0" y="213.8" fill="#9A96BE">o</text>
<rect x="8.0" y="223.6" width="8.4" height="19.6" fill="#2A1D41"/>
<rect x="16.4" y="223.6" width="8.4" height="19.6" fill="#291D41"/>
<rect x="24.8" y="223.6" width="8.4" height="19.6" fill="#291D40"/>
@@ -1633,8 +1633,8 @@
<rect x="402.8" y="223.6" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="411.2" y="223.6" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="419.6" y="223.6" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="428.0" y="223.6" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="436.4" y="223.6" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="428.0" y="223.6" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="436.4" y="223.6" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="444.8" y="223.6" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="453.2" y="223.6" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="461.6" y="223.6" width="8.4" height="19.6" fill="#0F0F1F"/>
@@ -1735,6 +1735,8 @@
<path d="M 394.4000 233.4000 L 402.8000 233.4000" fill="none" stroke="#434265" stroke-width="1.0500" shape-rendering="crispEdges"/>
<path d="M 402.8000 233.4000 L 411.2000 233.4000" fill="none" stroke="#434265" stroke-width="1.0500" shape-rendering="crispEdges"/>
<path d="M 411.2000 233.4000 L 419.6000 233.4000" fill="none" stroke="#434265" stroke-width="1.0500" shape-rendering="crispEdges"/>
<path d="M 419.6000 233.4000 L 428.0000 233.4000" fill="none" stroke="#434265" stroke-width="1.0500" shape-rendering="crispEdges"/>
<path d="M 428.0000 233.4000 L 436.4000 233.4000" fill="none" stroke="#434265" stroke-width="1.0500" shape-rendering="crispEdges"/>
<rect x="8.0" y="243.2" width="8.4" height="19.6" fill="#291D40"/>
<rect x="16.4" y="243.2" width="8.4" height="19.6" fill="#291D40"/>
<rect x="24.8" y="243.2" width="8.4" height="19.6" fill="#281C3F"/>
@@ -1785,8 +1787,8 @@
<rect x="402.8" y="243.2" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="411.2" y="243.2" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="419.6" y="243.2" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="428.0" y="243.2" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="436.4" y="243.2" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="428.0" y="243.2" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="436.4" y="243.2" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="444.8" y="243.2" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="453.2" y="243.2" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="461.6" y="243.2" width="8.4" height="19.6" fill="#0F0F1F"/>
@@ -1879,7 +1881,7 @@
<text x="176.0" y="253.0" fill="#F5F5F6">r</text>
<rect x="310.40" y="243.20" width="1.05" height="19.60" fill="#54468A"/>
<text x="335.6" y="253.0" fill="#F5F5F6">GraphQL</text>
<text x="411.2" y="253.0" fill="#9A96BE">q</text>
<text x="428.0" y="253.0" fill="#9A96BE">q</text>
<rect x="8.0" y="262.8" width="8.4" height="19.6" fill="#281C3F"/>
<rect x="16.4" y="262.8" width="8.4" height="19.6" fill="#281C3F"/>
<rect x="24.8" y="262.8" width="8.4" height="19.6" fill="#271C3E"/>
@@ -1930,8 +1932,8 @@
<rect x="402.8" y="262.8" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="411.2" y="262.8" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="419.6" y="262.8" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="428.0" y="262.8" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="436.4" y="262.8" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="428.0" y="262.8" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="436.4" y="262.8" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="444.8" y="262.8" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="453.2" y="262.8" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="461.6" y="262.8" width="8.4" height="19.6" fill="#0F0F1F"/>
@@ -2025,7 +2027,7 @@
<text x="176.0" y="272.6" fill="#F5F5F6">r</text>
<rect x="310.40" y="262.80" width="1.05" height="19.60" fill="#534589"/>
<text x="335.6" y="272.6" fill="#F5F5F6">gRPC</text>
<text x="411.2" y="272.6" fill="#9A96BE">r</text>
<text x="428.0" y="272.6" fill="#9A96BE">r</text>
<rect x="8.0" y="282.4" width="8.4" height="19.6" fill="#271C3E"/>
<rect x="16.4" y="282.4" width="8.4" height="19.6" fill="#271C3E"/>
<rect x="24.8" y="282.4" width="8.4" height="19.6" fill="#271C3D"/>
@@ -2064,20 +2066,20 @@
<rect x="302.0" y="282.4" width="8.4" height="19.6" fill="#1D1930"/>
<rect x="310.4" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="318.8" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="327.2" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="335.6" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="344.0" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="352.4" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="360.8" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="369.2" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="377.6" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="386.0" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="394.4" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="402.8" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="411.2" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="419.6" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="428.0" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="436.4" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="327.2" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="335.6" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="344.0" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="352.4" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="360.8" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="369.2" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="377.6" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="386.0" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="394.4" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="402.8" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="411.2" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="419.6" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="428.0" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="436.4" y="282.4" width="8.4" height="19.6" fill="#1E1E3F"/>
<rect x="444.8" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="453.2" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
<rect x="461.6" y="282.4" width="8.4" height="19.6" fill="#0F0F1F"/>
@@ -2165,6 +2167,8 @@
<text x="134.0" y="292.2" class="bold italic" fill="#DDA8FB">se</text>
<text x="150.8" y="292.2" class="bold italic" fill="#DDA8FB">r</text>
<rect x="310.40" y="282.40" width="1.05" height="19.60" fill="#514587"/>
<text x="335.6" y="292.2" fill="#F5F5F6">WebSocket</text>
<text x="428.0" y="292.2" fill="#9A96BE">w</text>
<rect x="8.0" y="302.0" width="8.4" height="19.6" fill="#261C3D"/>
<rect x="16.4" y="302.0" width="8.4" height="19.6" fill="#261C3D"/>
<rect x="24.8" y="302.0" width="8.4" height="19.6" fill="#261C3C"/>

Before

Width:  |  Height:  |  Size: 356 KiB

After

Width:  |  Height:  |  Size: 356 KiB

File diff suppressed because one or more lines are too long
+7 -8
View File
@@ -1175,14 +1175,13 @@
<rect x="209.60" y="145.20" width="8.40" height="19.60" fill="#C45AFF"/>
<rect x="209.60" y="145.20" width="6.30" height="19.60" fill="#131224"/>
<text x="234.8" y="155.0" class="bold" fill="#F5F5F6">alt+j</text>
<text x="419.6" y="155.0" fill="#9A96BE">Open</text>
<text x="461.6" y="155.0" fill="#9A96BE">a</text>
<text x="478.4" y="155.0" fill="#9A96BE">gRPC</text>
<text x="520.4" y="155.0" fill="#9A96BE">stream,</text>
<text x="587.6" y="155.0" fill="#9A96BE">or</text>
<text x="612.8" y="155.0" fill="#9A96BE">finish</text>
<text x="671.6" y="155.0" fill="#9A96BE">sending</text>
<text x="738.8" y="155.0" fill="#9A96BE">messages</text>
<text x="419.6" y="155.0" fill="#9A96BE">Toggle</text>
<text x="478.4" y="155.0" fill="#9A96BE">a</text>
<text x="495.2" y="155.0" fill="#9A96BE">gRPC</text>
<text x="537.2" y="155.0" fill="#9A96BE">stream</text>
<text x="596.0" y="155.0" fill="#9A96BE">or</text>
<text x="621.2" y="155.0" fill="#9A96BE">WebSocket</text>
<text x="705.2" y="155.0" fill="#9A96BE">connection</text>
<rect x="890.00" y="145.20" width="2.10" height="19.60" fill="#C45AFF"/>
<rect x="8.0" y="164.8" width="8.4" height="19.6" fill="#151226"/>
<rect x="16.4" y="164.8" width="8.4" height="19.6" fill="#151226"/>

Before

Width:  |  Height:  |  Size: 358 KiB

After

Width:  |  Height:  |  Size: 358 KiB

+8 -2
View File
@@ -45,6 +45,13 @@ func (u urlBar) Build(ctx t.BuildContext) t.Widget {
barColor = theme.Error
}
resolve := a.resolver()
sendLabel := a.icons.send + "Send"
if s.kind.Get() == model.KindWebSocket && s.streaming.Get() != streamOpen {
sendLabel = "Connect"
if s.phase.Get() == exchangeSending {
sendLabel = "Connecting…"
}
}
return t.Column{
Style: t.Style{Width: t.Flex(1), Padding: t.EdgeInsetsTRBL(1, 1, 0, 0)},
Children: []t.Widget{
@@ -81,10 +88,9 @@ func (u urlBar) Build(ctx t.BuildContext) t.Widget {
t.Button{
ID: sendButtonID,
DisableFocus: true,
Label: a.icons.send + "Send",
Label: sendLabel,
Variant: t.ButtonPrimary,
OnPress: a.send,
Click: func(t.MouseEvent) { a.send() },
},
},
},
+157
View File
@@ -0,0 +1,157 @@
package ui
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/darrenburns/posting/v3/internal/client"
"github.com/darrenburns/posting/v3/internal/history"
"github.com/darrenburns/posting/v3/internal/model"
t "github.com/darrenburns/terma"
"github.com/gorilla/websocket"
)
func TestWebSocketConnectSendInspectDisconnectAndHistory(tt *testing.T) {
for _, tc := range []struct {
theme string
width int
}{{"galaxy", 130}, {"catppuccin-latte", 82}} {
tt.Run(tc.theme, func(tt *testing.T) {
frames := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
closed := make(chan struct{})
type message struct {
typ int
body []byte
}
echoes := make(chan message)
go func() {
defer close(closed)
for {
typ, body, err := conn.ReadMessage()
if err != nil {
return
}
echoes <- message{typ, body}
}
}()
for {
select {
case message := <-echoes:
if err := conn.WriteMessage(message.typ, message.body); err != nil {
return
}
case frame := <-frames:
if err := conn.WriteMessage(websocket.TextMessage, []byte(frame)); err != nil {
return
}
case <-closed:
return
}
}
}))
defer server.Close()
app := homeApp(tc.theme)
app.sender = &client.WebSocket{HTTP: client.NewHTTP("posting-test", client.TLSSettings{})}
req := model.WebSocketKind.New()
req.Name, req.URL = "Deployment updates", strings.Replace(server.URL, "http", "ws", 1)
req.Payload = model.WebSocket{Message: `{"service":"api","progress":25}`}
app.openRequest(req)
s := app.current()
defer s.Cancel()
updates := make(chan func(), 64)
s.dispatch = func(fn func()) { updates <- fn }
p := t.NewPilot(tt, app, tc.width, snapH)
if !strings.Contains(p.TextOf(sendButtonID), "Connect") {
tt.Fatal("no connect button")
}
p.Click(sendButtonID)
drainUntil(tt, updates, func() bool { return s.live && s.streaming.Peek() == streamOpen })
p.Advance(0)
if len(s.response.Peek().Events) != 1 || !strings.Contains(p.ScreenText(), "WebSocket connected") {
tt.Fatal("connect sent the draft or failed to display")
}
if strings.Contains(p.ScreenText(), "not connected") {
tt.Fatal("clicking Connect also tried to send")
}
p.Click(sendButtonID)
drainUntil(tt, updates, func() bool { return len(s.response.Peek().Events) == 3 })
p.Advance(0)
t.RequestFocus(messagesID)
p.Advance(0)
p.Press("enter")
if !strings.Contains(p.TextOf("resp-body"), `"progress": 25`) {
tt.Fatal("echo not shown")
}
p.Press("t")
if _, ok := p.Bounds(responseJSONQueryID); !ok {
tt.Fatal("no JSON inspector")
}
frames <- `{"service":"api","progress":80}`
drainUntil(tt, updates, func() bool { return len(s.response.Peek().Events) == 4 })
p.Advance(0) // No keyboard/mouse input accompanies this arrival.
if !strings.Contains(p.ScreenText(), "1 new") || !strings.Contains(p.TextOf("resp-body"), "25") || strings.Contains(p.TextOf("resp-body"), "80") {
tt.Fatalf("arrival moved the inspected payload:\n%s", p.ScreenText())
}
p.Press("escape")
if s.phase.Peek() != exchangeSending {
tt.Fatal("leaving inspector disconnected")
}
p.Press("f")
if !strings.Contains(s.responseBody.GetText(), "80") {
tt.Fatal("follow didn't select latest")
}
e := s.payloads[model.KindWebSocket].(*webSocketEditor)
e.binary.Checked.Set(true)
e.message.SetText("AP+A")
p.Press("ctrl+j")
drainUntil(tt, updates, func() bool { return len(s.response.Peek().Events) == 6 })
p.Advance(0)
p.Press("enter")
if !strings.Contains(p.ScreenText(), "binary (base64)") || strings.TrimSpace(p.TextOf("resp-body")) != "AP+A" {
tt.Fatalf("binary inspector wrong:\n%s", p.ScreenText())
}
p.Press("escape")
p.Press("f")
e.binary.Checked.Set(false)
e.message.SetText("")
p.Press("ctrl+j")
drainUntil(tt, updates, func() bool { return len(s.response.Peek().Events) == 8 })
p.Advance(0)
p.Press("enter")
if !strings.Contains(p.ScreenText(), "Empty message") {
tt.Fatal("empty message lost")
}
p.Press("escape")
p.Press("alt+j")
drainUntil(tt, updates, func() bool { return s.phase.Peek() == exchangeDone })
p.Advance(0)
if s.responseStatus.Code != "1000" || strings.Contains(p.ScreenText(), "Streaming") {
tt.Fatal("disconnect failed")
}
store := history.ForCollection(tt.TempDir(), "/websocket")
if len(app.history.Peek()) != 1 {
tt.Fatal("no history entry")
}
if _, err := store.Append(app.history.Peek()[0]); err != nil {
tt.Fatal(err)
}
loaded, err := store.Load()
if err != nil || len(loaded) != 1 || len(loaded[0].Response.Events) != 9 || loaded[0].Response.Events[5].FrameType != "binary" || string(loaded[0].Response.Events[5].Body) != string([]byte{0, 255, 128}) {
tt.Fatal("history lost messages", err)
}
app.openHistory(loaded[0])
p.Advance(0)
if !strings.Contains(p.ScreenText(), "HISTORY") || !strings.Contains(p.ScreenText(), "disconnected: 1000") {
tt.Fatalf("history not visible:\n%s", p.ScreenText())
}
})
}
}