client: tidy codes
Kim committed
Oct 23, 2025 at 11:14 UTC
6986264789e4a5ef907b16d45be416fd3a7af7e1
9 files changed
+138
-124
README.md
+5
-36
@@ -71,33 +71,11 @@ If you run the chat client:
71
72
### 3. Embed the Client SDK in Your App
73
74
-Install the Go SDK module:
75
-```bash
76
-go get github.com/gosuda/relaydns/sdk/go
77
-```
78
-
79
-Minimal snippet (defaults for Protocol/Topic are applied by the SDK):
80
-```go
81
-package main
82
-
83
-import (
84
- "context"
85
- "time"
86
- "github.com/gosuda/relaydns/sdk/go"
87
-)
88
-
89
-func main() {
90
- ctx := context.Background()
91
- c, _ := sdk.NewClient(ctx, sdk.ClientConfig{
92
- ServerURL: "http://relaydns.gosuda.org",
93
- TargetTCP: "127.0.0.1:8081",
94
- Name: "demo-http",
95
- })
96
- _ = c.Start(ctx)
97
- defer c.Close()
98
- select {}
99
-}
100
-```
74
+See SDK quickstarts and examples:
75
+- Go: sdk/go/README.md
76
+- TypeScript: sdk/typescript (WIP)
77
+- Python: sdk/python (WIP)
78
+- Rust: sdk/rust (WIP)
79
80
## Configuration Reference
81
@@ -114,15 +92,6 @@ Chat client flags (see `make chat-run`):
92
- `--port` Local chat HTTP port (default `8091`)
93
- `--name` Display name (shown on server UI)
94
117
-## Other SDKs
118
-
119
-The repository includes placeholders for additional SDKs under `sdk/`:
120
-- TypeScript: `sdk/typescript` — TODO
121
-- Python: `sdk/python` — TODO
122
-- Rust: `sdk/rust` — TODO
123
-
124
-These will mirror the Go SDK ergonomics (simple defaults, minimal wiring). Contributions or early feedback on desired APIs are welcome.
125
-
95
## Deploying the Server (public)
96
97
- Expose ports:
go.mod
+1
@@ -6,6 +6,7 @@ require (
6
github.com/cockroachdb/crlib v0.0.0-20251001180057-2a49e1873587
7
github.com/cockroachdb/pebble v1.1.5
8
github.com/coder/websocket v1.8.14
9
+ github.com/go-chi/chi/v5 v5.2.3
10
github.com/libp2p/go-libp2p v0.44.0
11
github.com/libp2p/go-libp2p-pubsub v0.15.0
12
github.com/multiformats/go-multiaddr v0.16.0
go.sum
+2
@@ -63,6 +63,8 @@ github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK
63
github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY=
64
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
65
github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
66
+github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
67
+github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
68
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
69
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
70
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
sdk/go/README.md
new
+53
@@ -0,0 +1,53 @@
1
+# Go SDK Quickstart
2
+
3
+This SDK helps you advertise a local TCP service (e.g., HTTP) over libp2p so a RelayDNS server can discover and proxy to it.
4
+
5
+## Minimal example (HTTP backend)
6
+
7
+```go
8
+package main
9
+
10
+import (
11
+ "context"
12
+ "fmt"
13
+ "log"
14
+
15
+ sdk "github.com/gosuda/relaydns/sdk/go"
16
+)
17
+
18
+func main() {
19
+ ctx := context.Background()
20
+
21
+ // Assume your local HTTP server listens on 127.0.0.1:8080
22
+ cli, err := sdk.NewClient(ctx, sdk.ClientConfig{
23
+ Name: "example-backend",
24
+ TargetTCP: "127.0.0.1:8080",
25
+ ServerURL: "http://relaydns.gosuda.org",
26
+ })
27
+ if err != nil { log.Fatal(err) }
28
+
29
+ if err := cli.Start(ctx); err != nil { log.Fatal(err) }
30
+ defer func() { _ = cli.Close() }()
31
+
32
+ // Blocks in your real app; here you’d run until SIGINT, etc.
33
+ fmt.Println("status:", cli.ServerStatus())
34
+}
35
+```
36
+
37
+## Key concepts
38
+
39
+- `TargetTCP`: If set, inbound libp2p streams are proxied directly to the local TCP service using a bidirectional byte pipe.
40
+- `ServerURL`: If set, the client periodically fetches multiaddrs from `/hosts` and attempts to connect to the server’s peer; `ServerStatus()` reports `Connected` only when libp2p connectivity is established.
41
+- Defaults: Reasonable defaults are applied (protocol/topic, advertise intervals, reasonable HTTP timeout, and address sorting that prefers QUIC and local addresses).
42
+
43
+## Lifecycle
44
+
45
+- `NewClient` creates the libp2p host and applies defaults.
46
+- `Start` installs the stream handler, joins pubsub, begins advertising, and starts background refresh (if `ServerURL` is set).
47
+- `Close` stops background goroutines, removes the handler, and closes the host.
48
+
49
+See working examples under `sdk/go/examples/`:
50
+
51
+- `http-client`: simple HTTP backend with a status page.
52
+- `chat`: WebSocket chat demo; demonstrates HTTP upgrade tunneling.
53
+
sdk/go/client.go
+28
-28
@@ -20,6 +20,7 @@ import (
20
"github.com/gosuda/relaydns/relaydns"
21
)
22
23
+// ClientConfig configures the client. Defaults apply; set Handler or TargetTCP.
24
type ClientConfig struct {
25
ServerURL string
26
Bootstraps []string
@@ -27,27 +28,23 @@ type ClientConfig struct {
28
PreferQUIC bool
29
PreferLocal bool
30
30
- // libp2p stream protocol id (e.g. "/relaydns/ssh/1.0")
31
+ // Protocol (e.g. "/relaydns/http/1.0"); Topic for adverts.
32
Protocol string
32
- // pubsub topic for backend adverts (e.g. "relaydns.backends")
33
- Topic string
34
- // advertise interval
35
- AdvertiseEvery time.Duration
36
- // advertise TTL (how long server should keep this entry alive)
37
- AdvertiseTTL time.Duration
38
- // how often to refresh server health/bootstraps (if ServerURL set)
33
+ Topic string
34
+
35
+ AdvertiseEvery time.Duration
36
+ AdvertiseTTL time.Duration
37
RefreshBootstrapsEvery time.Duration
40
- // optional metadata
38
+
39
Name string
40
DNS string
41
44
- // One of the following:
45
- // 1) Provide a custom stream handler
46
- Handler func(s network.Stream)
47
- // 2) Or just set TargetTCP to auto-pipe bytes to a local TCP service (e.g. "127.0.0.1:22")
42
+ // Set one: custom stream Handler, or TargetTCP to proxy bytes to a local TCP service.
43
+ Handler func(s network.Stream)
44
TargetTCP string
45
}
46
47
+// RelayClient advertises over pubsub and handles inbound streams.
48
type RelayClient struct {
49
h host.Host
50
cfg ClientConfig
@@ -96,8 +93,7 @@ func applyDefaults(cfg ClientConfig) ClientConfig {
93
return cfg
94
}
95
99
-// NewClient constructs a client with defaults applied and an initialized libp2p host.
100
-// It does not start networking. Call Start(ctx) to begin handlers, pubsub, and advertising.
96
+// NewClient creates a client (not started) with defaults applied.
97
func NewClient(ctx context.Context, cfg ClientConfig) (*RelayClient, error) {
98
cfg = applyDefaults(cfg)
99
@@ -113,7 +109,7 @@ func NewClient(ctx context.Context, cfg ClientConfig) (*RelayClient, error) {
109
return b, nil
110
}
111
116
-// Start connects bootstraps, sets stream handler, joins pubsub, and starts advertising.
112
+// Start wires handlers, joins pubsub, advertises, and refreshes bootstraps.
113
func (b *RelayClient) Start(ctx context.Context) error {
114
if b.stop != nil {
115
return fmt.Errorf("client already started")
@@ -174,6 +170,7 @@ func (b *RelayClient) Host() host.Host {
170
return b.h
171
}
172
173
+// Close stops all loops, removes the handler, and closes the host.
174
func (b *RelayClient) Close() error {
175
if b.stop != nil {
176
b.stop()
@@ -217,33 +214,37 @@ func (b *RelayClient) resolveBootstraps() []string {
214
return relaydns.RemoveDuplicate(boot)
215
}
216
220
-// setupStreamHandler installs the appropriate libp2p stream handler.
217
+// setupStreamHandler installs the handler. TargetTCP proxies stream <-> local TCP.
218
func (b *RelayClient) setupStreamHandler() error {
219
switch {
220
case b.cfg.Handler != nil:
221
b.h.SetStreamHandler(b.protoID, b.cfg.Handler)
222
case b.cfg.TargetTCP != "":
223
b.h.SetStreamHandler(b.protoID, func(s network.Stream) {
224
+ stream := s
225
+ backendAddr := b.cfg.TargetTCP
226
defer func() {
228
- if err := s.Close(); err != nil {
227
+ if err := stream.Close(); err != nil {
228
log.Debug().Err(err).Msg("relaydns: stream close")
229
}
230
}()
232
- c, err := net.Dial("tcp", b.cfg.TargetTCP)
231
+
232
+ backendConn, err := net.Dial("tcp", backendAddr)
233
if err != nil {
234
- log.Error().Err(err).Msgf("relaydns: dial %s", b.cfg.TargetTCP)
234
+ log.Error().Err(err).Msgf("relaydns: dial %s", backendAddr)
235
return
236
}
237
defer func() {
238
- if err := c.Close(); err != nil {
238
+ if err := backendConn.Close(); err != nil {
239
log.Debug().Err(err).Msg("relaydns: conn close")
240
}
241
}()
242
- // raw byte pipe (bidirectional)
243
- go func() {
244
- _, _ = io.Copy(c, s)
245
- }()
246
- _, _ = io.Copy(s, c)
242
+
243
+ var wg sync.WaitGroup
244
+ wg.Add(2)
245
+ go func() { defer wg.Done(); _, _ = io.Copy(backendConn, stream) }()
246
+ go func() { defer wg.Done(); _, _ = io.Copy(stream, backendConn) }()
247
+ wg.Wait()
248
})
249
default:
250
return fmt.Errorf("relaydns: either Handler or TargetTCP must be set")
@@ -348,8 +349,7 @@ func serverPeerFromAddrs(addrs []string) (peer.ID, bool) {
349
return "", false
350
}
351
351
-// ServerStatus returns a human-friendly status regarding connection to ServerURL.
352
-// If no ServerURL is configured, returns "N/A".
352
+// ServerStatus returns "Connected", "Connecting...", or "N/A".
353
func (b *RelayClient) ServerStatus() string {
354
if b.cfg.ServerURL == "" {
355
return "N/A"
sdk/go/examples/chat/main.go
+14
-8
@@ -4,6 +4,7 @@ import (
4
"context"
5
"fmt"
6
"net"
7
+ "net/http"
8
"os"
9
"os/signal"
10
"syscall"
@@ -47,7 +48,7 @@ func runChat(cmd *cobra.Command, args []string) error {
48
defer cancel() // Ensure context is cancelled on all exit paths
49
50
// 1) start local chat HTTP backend
50
- ln, err := net.Listen("tcp", fmt.Sprintf(":%d", flagPort))
51
+ httpLn, err := net.Listen("tcp", fmt.Sprintf(":%d", flagPort))
52
if err != nil {
53
return fmt.Errorf("listen chat: %w", err)
54
}
@@ -70,10 +71,17 @@ func runChat(cmd *cobra.Command, args []string) error {
71
hub.attachStore(store)
72
}
73
}
73
- srv := serveChatHTTP(ln, flagName, hub)
74
+ // Build router and run server in main
75
+ handler := NewHandler(flagName, hub)
76
+ httpSrv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}
77
+ go func() {
78
+ if err := httpSrv.Serve(httpLn); err != nil && err != http.ErrServerClosed {
79
+ log.Error().Err(err).Msg("chat http error")
80
+ }
81
+ }()
82
83
// 2) advertise over RelayDNS (HTTP tunneled via server /peer route)
76
- cli, err := sdk.NewClient(ctx, sdk.ClientConfig{
84
+ client, err := sdk.NewClient(ctx, sdk.ClientConfig{
85
Name: flagName,
86
TargetTCP: fmt.Sprintf("127.0.0.1:%d", flagPort),
87
ServerURL: flagServerURL,
@@ -81,7 +89,7 @@ func runChat(cmd *cobra.Command, args []string) error {
89
if err != nil {
90
return fmt.Errorf("new client: %w", err)
91
}
84
- if err := cli.Start(ctx); err != nil {
92
+ if err := client.Start(ctx); err != nil {
93
return fmt.Errorf("start client: %w", err)
94
}
95
@@ -91,17 +99,15 @@ func runChat(cmd *cobra.Command, args []string) error {
99
log.Info().Msg("[chat] shutting down...")
100
101
// Shutdown sequence:
94
- // Note: defer cancel() at function start stops client advertising/refresh loops
95
-
102
// 1. Close client (waits for goroutines, closes libp2p host)
97
- if err := cli.Close(); err != nil {
103
+ if err := client.Close(); err != nil {
104
log.Warn().Err(err).Msg("[chat] client close error")
105
}
106
107
// 2. Shutdown HTTP server with a fresh context (with timeout)
108
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
109
defer shutdownCancel()
104
- if err := srv.Shutdown(shutdownCtx); err != nil {
110
+ if err := httpSrv.Shutdown(shutdownCtx); err != nil {
111
log.Error().Err(err).Msg("[chat] http server shutdown error")
112
}
113
sdk/go/examples/chat/view.go
+7
-15
@@ -3,7 +3,6 @@ package main
3
import (
4
"context"
5
"html/template"
6
- "net"
6
"net/http"
7
"sort"
8
"strconv"
@@ -12,6 +11,7 @@ import (
11
12
"github.com/coder/websocket"
13
"github.com/coder/websocket/wsjson"
14
+ "github.com/go-chi/chi/v5"
15
"github.com/rs/zerolog/log"
16
)
17
@@ -235,20 +235,12 @@ func serveIndex(w http.ResponseWriter, r *http.Request, name string) {
235
236
// serveChatHTTP starts serving the chat UI and websocket endpoint and returns the server.
237
// Callers are responsible for shutting it down via Server.Shutdown.
238
-func serveChatHTTP(ln net.Listener, name string, h *hub) *http.Server {
239
- mux := http.NewServeMux()
240
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { serveIndex(w, r, name) })
241
- mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { handleWS(w, r, h) })
242
-
243
- srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}
244
- log.Info().Msgf("[chat] http listening on %s", ln.Addr().String())
245
-
246
- go func() {
247
- if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
248
- log.Error().Err(err).Msg("chat http error")
249
- }
250
- }()
251
- return srv
238
+// NewHandler builds the chat HTTP router (UI + websocket)
239
+func NewHandler(name string, h *hub) http.Handler {
240
+ r := chi.NewRouter()
241
+ r.Get("/", func(w http.ResponseWriter, r *http.Request) { serveIndex(w, r, name) })
242
+ r.Get("/ws", func(w http.ResponseWriter, r *http.Request) { handleWS(w, r, h) })
243
+ return r
244
}
245
246
var indexTmpl = template.Must(template.New("chat").Parse(`<!DOCTYPE html>
sdk/go/examples/http-client/main.go
+20
-17
@@ -4,6 +4,7 @@ import (
4
"context"
5
"fmt"
6
"net"
7
+ "net/http"
8
"os"
9
"os/signal"
10
"syscall"
@@ -44,24 +45,28 @@ func runClient(cmd *cobra.Command, args []string) error {
45
ctx, cancel := context.WithCancel(context.Background())
46
defer cancel() // Ensure context is cancelled on all exit paths
47
47
- // 1) HTTP backend
48
- var clientRef *sdk.RelayClient
49
- ln, err := net.Listen("tcp", fmt.Sprintf(":%d", flagPort))
48
+ // 1) start local HTTP backend
49
+ var relayClient *sdk.RelayClient
50
+ httpLn, err := net.Listen("tcp", fmt.Sprintf(":%d", flagPort))
51
if err != nil {
52
log.Fatal().Err(err).Msg("failed to listen")
53
}
53
- log.Info().Msgf("[client] local backend http listening on %s", ln.Addr().String())
54
-
55
- // Serve local backend view and keep a server handle for shutdown
56
- srv := serveClientHTTP(ln, flagName, func() string {
57
- if clientRef == nil {
54
+ log.Info().Msgf("[client] local backend http listening on %s", httpLn.Addr().String())
55
+ handler := NewHandler(httpLn.Addr().String(), flagName, func() string {
56
+ if relayClient == nil {
57
return "Starting..."
58
}
60
- return clientRef.ServerStatus()
59
+ return relayClient.ServerStatus()
60
})
61
+ httpSrv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}
62
+ go func() {
63
+ if err := httpSrv.Serve(httpLn); err != nil && err != http.ErrServerClosed {
64
+ log.Error().Err(err).Msg(" http backend error")
65
+ }
66
+ }()
67
63
- // 2) libp2p host
64
- rc, err := sdk.NewClient(ctx, sdk.ClientConfig{
68
+ // 2) libp2p client
69
+ client, err := sdk.NewClient(ctx, sdk.ClientConfig{
70
Name: flagName,
71
TargetTCP: fmt.Sprintf("127.0.0.1:%d", flagPort),
72
ServerURL: flagServerURL,
@@ -69,10 +74,10 @@ func runClient(cmd *cobra.Command, args []string) error {
74
if err != nil {
75
return fmt.Errorf("new client: %w", err)
76
}
72
- if err := rc.Start(ctx); err != nil {
77
+ if err := client.Start(ctx); err != nil {
78
return fmt.Errorf("start client: %w", err)
79
}
75
- clientRef = rc
80
+ relayClient = client
81
82
// wait for termination
83
sig := make(chan os.Signal, 1)
@@ -81,17 +86,15 @@ func runClient(cmd *cobra.Command, args []string) error {
86
log.Info().Msg("[client] shutting down...")
87
88
// Shutdown sequence:
84
- // Note: defer cancel() at function start stops client advertising/refresh loops
85
-
89
// 1. Close client (waits for goroutines, closes libp2p host)
87
- if err := rc.Close(); err != nil {
90
+ if err := client.Close(); err != nil {
91
log.Warn().Err(err).Msg("[client] client close error")
92
}
93
94
// 2. Shutdown HTTP server with a fresh context (with timeout)
95
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
96
defer shutdownCancel()
94
- if err := srv.Shutdown(shutdownCtx); err != nil {
97
+ if err := httpSrv.Shutdown(shutdownCtx); err != nil {
98
log.Error().Err(err).Msg("[client] http server shutdown error")
99
}
100
sdk/go/examples/http-client/view.go
+8
-20
@@ -2,18 +2,17 @@ package main
2
3
import (
4
"html/template"
5
- "net"
5
"net/http"
6
"time"
7
9
- "github.com/rs/zerolog/log"
8
+ "github.com/go-chi/chi/v5"
9
)
10
12
-// serveClientHTTP serves the simple local backend UI and health endpoint.
11
+// serveClientHTTP builds the local backend router using chi.
12
// getStatus should return a short string like "Connected" or "Connecting...".
14
-func serveClientHTTP(ln net.Listener, name string, getStatus func() string) *http.Server {
15
- mux := http.NewServeMux()
16
- mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
13
+func NewHandler(addr string, name string, getStatus func() string) http.Handler {
14
+ r := chi.NewRouter()
15
+ r.Get("/", func(w http.ResponseWriter, r *http.Request) {
16
status := getStatus()
17
statusClass := "disconnected"
18
if status == "Connected" {
@@ -28,28 +27,17 @@ func serveClientHTTP(ln net.Listener, name string, getStatus func() string) *htt
27
}{
28
Now: time.Now().Format(time.RFC1123),
29
Name: name,
31
- Addr: ln.Addr().String(),
30
+ Addr: addr,
31
Status: status,
32
StatusClass: statusClass,
33
}
34
_ = clientPage.Execute(w, data)
35
})
37
- mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
36
+ r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
37
w.WriteHeader(http.StatusOK)
38
_, _ = w.Write([]byte("ok"))
39
})
41
-
42
- srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}
43
-
44
- log.Info().Msgf("[client] local backend http %s", ln.Addr().String())
45
-
46
- // Serve in background
47
- go func() {
48
- if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
49
- log.Error().Err(err).Msg(" http backend error")
50
- }
51
- }()
52
- return srv
40
+ return r
41
}
42
43
var clientPage = template.Must(template.New("index").Parse(`<!DOCTYPE html>