client.go

  1package client
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"net"
  8	"net/http"
  9	"path/filepath"
 10	"runtime"
 11	"strings"
 12	"time"
 13
 14	"github.com/charmbracelet/crush/internal/config"
 15	"github.com/charmbracelet/crush/internal/proto"
 16	"github.com/charmbracelet/crush/internal/server"
 17)
 18
 19// Client represents an RPC client connected to a Crush server.
 20type Client struct {
 21	h    *http.Client
 22	id   string
 23	path string
 24}
 25
 26// DefaultClient creates a new [Client] connected to the default server address.
 27func DefaultClient(path string) (*Client, error) {
 28	proto, addr, ok := strings.Cut(server.DefaultHost(), "://")
 29	if !ok {
 30		return nil, fmt.Errorf("failed to determine default server address for platform %s", runtime.GOOS)
 31	}
 32	return NewClient(path, proto, addr)
 33}
 34
 35// NewClient creates a new [Client] connected to the server at the given
 36// network and address.
 37func NewClient(path, network, address string) (*Client, error) {
 38	var p http.Protocols
 39	p.SetHTTP1(true)
 40	p.SetUnencryptedHTTP2(true)
 41	tr := http.DefaultTransport.(*http.Transport).Clone()
 42	tr.Protocols = &p
 43	tr.DialContext = dialer
 44	h := &http.Client{
 45		Transport: tr,
 46		Timeout:   0, // we need this to be 0 for long-lived connections and SSE streams
 47	}
 48	return &Client{
 49		h:    h,
 50		path: filepath.Clean(path),
 51	}, nil
 52}
 53
 54// ID returns the client's instance unique identifier.
 55func (c *Client) ID() string {
 56	return c.id
 57}
 58
 59// SetID sets the client's instance unique identifier.
 60func (c *Client) SetID(id string) {
 61	c.id = id
 62}
 63
 64// Path returns the client's instance filesystem path.
 65func (c *Client) Path() string {
 66	return c.path
 67}
 68
 69// GetGlobalConfig retrieves the server's configuration.
 70func (c *Client) GetGlobalConfig() (*config.Config, error) {
 71	var cfg config.Config
 72	rsp, err := c.h.Get("http://localhost/v1/config")
 73	if err != nil {
 74		return nil, err
 75	}
 76	defer rsp.Body.Close()
 77	if err := json.NewDecoder(rsp.Body).Decode(&cfg); err != nil {
 78		return nil, err
 79	}
 80	return &cfg, nil
 81}
 82
 83// Health checks the server's health status.
 84func (c *Client) Health() error {
 85	rsp, err := c.h.Get("http://localhost/v1/health")
 86	if err != nil {
 87		return err
 88	}
 89	defer rsp.Body.Close()
 90	if rsp.StatusCode != http.StatusOK {
 91		return fmt.Errorf("server health check failed: %s", rsp.Status)
 92	}
 93	return nil
 94}
 95
 96// VersionInfo retrieves the server's version information.
 97func (c *Client) VersionInfo() (*proto.VersionInfo, error) {
 98	var vi proto.VersionInfo
 99	rsp, err := c.h.Get("http://localhost/v1/version")
100	if err != nil {
101		return nil, err
102	}
103	defer rsp.Body.Close()
104	if err := json.NewDecoder(rsp.Body).Decode(&vi); err != nil {
105		return nil, err
106	}
107	return &vi, nil
108}
109
110// ShutdownServer sends a shutdown request to the server.
111func (c *Client) ShutdownServer() error {
112	req, err := http.NewRequest("POST", "http://localhost/v1/control", jsonBody(proto.ServerControl{
113		Command: "shutdown",
114	}))
115	if err != nil {
116		return err
117	}
118	rsp, err := c.h.Do(req)
119	if err != nil {
120		return err
121	}
122	defer rsp.Body.Close()
123	if rsp.StatusCode != http.StatusOK {
124		return fmt.Errorf("server shutdown failed: %s", rsp.Status)
125	}
126	return nil
127}
128
129func dialer(ctx context.Context, network, address string) (net.Conn, error) {
130	switch network {
131	case "npipe":
132		ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
133		defer cancel()
134		return dialPipeContext(ctx, address)
135	default:
136		d := net.Dialer{
137			Timeout:   30 * time.Second,
138			KeepAlive: 30 * time.Second,
139		}
140		return d.DialContext(ctx, network, address)
141	}
142}