mirror of
https://github.com/darrenburns/posting.git
synced 2026-10-16 15:34:25 +08:00
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:
+1
-1
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(©)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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}},
|
||||
}}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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("") })})
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+52
-48
@@ -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">"</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 |
+1
-1
File diff suppressed because one or more lines are too long
+7
-8
@@ -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 |
@@ -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() },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user