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}