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>