Add daemon client protocol frames
This commit is contained in:
164
internal/protocol/frame.go
Normal file
164
internal/protocol/frame.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// Package protocol defines the daemon/client control frames shared by
|
||||||
|
// transports. It intentionally contains data shapes only; app behavior stays
|
||||||
|
// in internal/app until the headless daemon split is complete.
|
||||||
|
package protocol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FrameType identifies one protocol message kind.
|
||||||
|
type FrameType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FrameHello FrameType = "hello"
|
||||||
|
FrameAuthChallenge FrameType = "auth_challenge"
|
||||||
|
FrameAuthOK FrameType = "auth_ok"
|
||||||
|
FrameAttach FrameType = "attach"
|
||||||
|
FrameDetach FrameType = "detach"
|
||||||
|
FrameProjectList FrameType = "project_list"
|
||||||
|
FrameChrome FrameType = "chrome"
|
||||||
|
FramePaneSnapshot FrameType = "pane_snapshot"
|
||||||
|
FramePaneChunk FrameType = "pane_chunk"
|
||||||
|
FrameLifecycle FrameType = "lifecycle"
|
||||||
|
FrameAttention FrameType = "attention"
|
||||||
|
FrameTrustPrompt FrameType = "trust_prompt"
|
||||||
|
FrameInput FrameType = "input"
|
||||||
|
FrameFocus FrameType = "focus"
|
||||||
|
FrameSwitchProject FrameType = "switch_project"
|
||||||
|
FrameOpenProject FrameType = "open_project"
|
||||||
|
FramePaletteCommand FrameType = "palette_command"
|
||||||
|
FrameTrustResponse FrameType = "trust_response"
|
||||||
|
FrameResize FrameType = "resize"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Frame is the transport envelope. Payload is deliberately raw JSON so
|
||||||
|
// network transports can frame without knowing every message type; loopback
|
||||||
|
// transports may pass the same bytes without JSON re-encoding.
|
||||||
|
type Frame struct {
|
||||||
|
Type FrameType `json:"type"`
|
||||||
|
RequestID string `json:"request_id,omitempty"`
|
||||||
|
Payload json.RawMessage `json:"payload,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFrame marshals payload into a protocol frame.
|
||||||
|
func NewFrame[T any](typ FrameType, payload T) (Frame, error) {
|
||||||
|
b, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return Frame{}, fmt.Errorf("protocol: marshal %s: %w", typ, err)
|
||||||
|
}
|
||||||
|
return Frame{Type: typ, Payload: b}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode unmarshals f.Payload into v.
|
||||||
|
func Decode[T any](f Frame) (T, error) {
|
||||||
|
var v T
|
||||||
|
if len(f.Payload) == 0 {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(f.Payload, &v); err != nil {
|
||||||
|
return v, fmt.Errorf("protocol: decode %s: %w", f.Type, err)
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hello struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
DaemonID string `json:"daemon_id,omitempty"`
|
||||||
|
ClientID string `json:"client_id,omitempty"`
|
||||||
|
ProjectKey string `json:"project_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Attach struct {
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
ProjectKey string `json:"project_key,omitempty"`
|
||||||
|
TermSize Size `json:"term_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Detach struct {
|
||||||
|
ClientID string `json:"client_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Size struct {
|
||||||
|
Cols uint16 `json:"cols"`
|
||||||
|
Rows uint16 `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Project struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
LastActive time.Time `json:"last_active,omitempty"`
|
||||||
|
TabCount int `json:"tab_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectList struct {
|
||||||
|
Projects []Project `json:"projects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Chrome struct {
|
||||||
|
ProjectKey string `json:"project_key"`
|
||||||
|
Model json.RawMessage `json:"model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaneSnapshot struct {
|
||||||
|
PaneID string `json:"pane_id"`
|
||||||
|
Bytes []byte `json:"bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaneChunk struct {
|
||||||
|
PaneID string `json:"pane_id"`
|
||||||
|
Bytes []byte `json:"bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LifecycleKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
LifecycleSpawned LifecycleKind = "spawned"
|
||||||
|
LifecycleExited LifecycleKind = "exited"
|
||||||
|
LifecycleClosed LifecycleKind = "closed"
|
||||||
|
LifecycleStateChanged LifecycleKind = "state_changed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Lifecycle struct {
|
||||||
|
Kind LifecycleKind `json:"kind"`
|
||||||
|
ProjectKey string `json:"project_key,omitempty"`
|
||||||
|
ChildID string `json:"child_id,omitempty"`
|
||||||
|
Child json.RawMessage `json:"child,omitempty"`
|
||||||
|
State string `json:"state,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Input struct {
|
||||||
|
PaneID string `json:"pane_id"`
|
||||||
|
Bytes []byte `json:"bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Focus struct {
|
||||||
|
PaneID string `json:"pane_id,omitempty"`
|
||||||
|
Pad string `json:"pad,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SwitchProject struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenProject struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PaletteCommand struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrustResponse struct {
|
||||||
|
ProcessID string `json:"process_id"`
|
||||||
|
Preset string `json:"preset"`
|
||||||
|
Allow bool `json:"allow"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Resize struct {
|
||||||
|
Size Size `json:"size"`
|
||||||
|
}
|
||||||
67
internal/protocol/loopback.go
Normal file
67
internal/protocol/loopback.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package protocol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const defaultLoopbackBuffer = 64
|
||||||
|
|
||||||
|
// NewLoopbackPair returns connected in-process transports. Frames cross the
|
||||||
|
// same Send/Recv boundary as network transports, but payload bytes are passed
|
||||||
|
// directly without JSON re-encoding.
|
||||||
|
func NewLoopbackPair() (client Transport, daemon Transport) {
|
||||||
|
c2d := make(chan Frame, defaultLoopbackBuffer)
|
||||||
|
d2c := make(chan Frame, defaultLoopbackBuffer)
|
||||||
|
return &loopbackTransport{send: c2d, recv: d2c}, &loopbackTransport{send: d2c, recv: c2d}
|
||||||
|
}
|
||||||
|
|
||||||
|
type loopbackTransport struct {
|
||||||
|
send chan<- Frame
|
||||||
|
recv <-chan Frame
|
||||||
|
once sync.Once
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *loopbackTransport) init() {
|
||||||
|
if t.done == nil {
|
||||||
|
t.done = make(chan struct{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *loopbackTransport) Send(f Frame) error {
|
||||||
|
t.init()
|
||||||
|
select {
|
||||||
|
case <-t.done:
|
||||||
|
return ErrTransportClosed
|
||||||
|
case t.send <- cloneFrame(f):
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *loopbackTransport) Recv() (Frame, error) {
|
||||||
|
t.init()
|
||||||
|
select {
|
||||||
|
case <-t.done:
|
||||||
|
return Frame{}, ErrTransportClosed
|
||||||
|
case f, ok := <-t.recv:
|
||||||
|
if !ok {
|
||||||
|
return Frame{}, ErrTransportClosed
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *loopbackTransport) Close() error {
|
||||||
|
t.init()
|
||||||
|
t.once.Do(func() {
|
||||||
|
close(t.done)
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneFrame(f Frame) Frame {
|
||||||
|
if len(f.Payload) > 0 {
|
||||||
|
f.Payload = append([]byte(nil), f.Payload...)
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
51
internal/protocol/loopback_test.go
Normal file
51
internal/protocol/loopback_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package protocol
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestLoopbackUsesFramePayload(t *testing.T) {
|
||||||
|
client, daemon := NewLoopbackPair()
|
||||||
|
defer client.Close()
|
||||||
|
defer daemon.Close()
|
||||||
|
|
||||||
|
sent, err := NewFrame(FrameInput, Input{PaneID: "p_123456", Bytes: []byte("hello")})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewFrame: %v", err)
|
||||||
|
}
|
||||||
|
if err := client.Send(sent); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
got, err := daemon.Recv()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recv: %v", err)
|
||||||
|
}
|
||||||
|
if got.Type != FrameInput {
|
||||||
|
t.Fatalf("type = %q, want %q", got.Type, FrameInput)
|
||||||
|
}
|
||||||
|
payload, err := Decode[Input](got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Decode: %v", err)
|
||||||
|
}
|
||||||
|
if payload.PaneID != "p_123456" || string(payload.Bytes) != "hello" {
|
||||||
|
t.Fatalf("payload = %#v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoopbackCopiesPayloadOnSend(t *testing.T) {
|
||||||
|
client, daemon := NewLoopbackPair()
|
||||||
|
defer client.Close()
|
||||||
|
defer daemon.Close()
|
||||||
|
|
||||||
|
f := Frame{Type: FramePaneChunk, Payload: []byte(`{"pane_id":"p","bytes":"aGVsbG8="}`)}
|
||||||
|
if err := client.Send(f); err != nil {
|
||||||
|
t.Fatalf("Send: %v", err)
|
||||||
|
}
|
||||||
|
f.Payload[0] = 'x'
|
||||||
|
|
||||||
|
got, err := daemon.Recv()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Recv: %v", err)
|
||||||
|
}
|
||||||
|
if got.Payload[0] != '{' {
|
||||||
|
t.Fatalf("payload was retained instead of copied: %q", string(got.Payload))
|
||||||
|
}
|
||||||
|
}
|
||||||
73
internal/protocol/transport.go
Normal file
73
internal/protocol/transport.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package protocol
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrTransportClosed = errors.New("protocol: transport closed")
|
||||||
|
|
||||||
|
// Transport carries framed daemon/client protocol messages.
|
||||||
|
type Transport interface {
|
||||||
|
Send(Frame) error
|
||||||
|
Recv() (Frame, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnTransport is a JSON-lines implementation over a stream connection.
|
||||||
|
type ConnTransport struct {
|
||||||
|
conn net.Conn
|
||||||
|
r *bufio.Reader
|
||||||
|
w *bufio.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewConnTransport(conn net.Conn) *ConnTransport {
|
||||||
|
return &ConnTransport{
|
||||||
|
conn: conn,
|
||||||
|
r: bufio.NewReader(conn),
|
||||||
|
w: bufio.NewWriter(conn),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnTransport) Send(f Frame) error {
|
||||||
|
if t == nil || t.conn == nil {
|
||||||
|
return ErrTransportClosed
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(f)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("protocol: encode frame: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := t.w.Write(append(b, '\n')); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return t.w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnTransport) Recv() (Frame, error) {
|
||||||
|
if t == nil || t.conn == nil {
|
||||||
|
return Frame{}, ErrTransportClosed
|
||||||
|
}
|
||||||
|
line, err := t.r.ReadBytes('\n')
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return Frame{}, ErrTransportClosed
|
||||||
|
}
|
||||||
|
return Frame{}, err
|
||||||
|
}
|
||||||
|
var f Frame
|
||||||
|
if err := json.Unmarshal(line, &f); err != nil {
|
||||||
|
return Frame{}, fmt.Errorf("protocol: decode frame: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *ConnTransport) Close() error {
|
||||||
|
if t == nil || t.conn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return t.conn.Close()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user