sdk: nomalize bootstrap server url

Kim committed Nov 17, 2025 at 11:02 UTC e3ca1f4c7b14eefe9b2eeb246d856aa761203c87
2 files changed +136 -3
sdk/sdk.go
+73 -3
@@ -7,6 +7,7 @@ import (
7 "fmt"
8 "io"
9 "net"
10 + "net/url"
11 "regexp"
12 "strings"
13 "sync"
@@ -217,6 +218,58 @@ func WithHide(hide bool) MetadataOption {
218 }
219 }
220
221 +// normalizeBootstrapServer takes various user-friendly server inputs and
222 +// converts them into a proper WebSocket URL.
223 +// Examples:
224 +// - "wss://localhost:4017/relay" -> unchanged
225 +// - "ws://localhost:4017/relay" -> unchanged
226 +// - "http://example.com" -> "ws://example.com/relay"
227 +// - "https://example.com" -> "wss://example.com/relay"
228 +// - "localhost:4017" -> "wss://localhost:4017/relay"
229 +// - "example.com" -> "wss://example.com/relay"
230 +func normalizeBootstrapServer(raw string) (string, error) {
231 + server := strings.TrimSpace(raw)
232 + if server == "" {
233 + return "", fmt.Errorf("bootstrap server is empty")
234 + }
235 +
236 + // Already a WebSocket URL
237 + if strings.HasPrefix(server, "ws://") || strings.HasPrefix(server, "wss://") {
238 + return server, nil
239 + }
240 +
241 + // HTTP/HTTPS -> WS/WSS with default /relay path
242 + if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
243 + u, err := url.Parse(server)
244 + if err != nil {
245 + return "", fmt.Errorf("invalid bootstrap server %q: %w", raw, err)
246 + }
247 + switch u.Scheme {
248 + case "http":
249 + u.Scheme = "ws"
250 + case "https":
251 + u.Scheme = "wss"
252 + }
253 + if u.Path == "" || u.Path == "/" {
254 + u.Path = "/relay"
255 + }
256 + return u.String(), nil
257 + }
258 +
259 + // Bare host[:port][/path] -> assume WSS and /relay if no path
260 + u, err := url.Parse("wss://" + server)
261 + if err != nil {
262 + return "", fmt.Errorf("invalid bootstrap server %q: %w", raw, err)
263 + }
264 + if u.Host == "" {
265 + return "", fmt.Errorf("invalid bootstrap server %q: missing host", raw)
266 + }
267 + if u.Path == "" || u.Path == "/" {
268 + u.Path = "/relay"
269 + }
270 + return u.String(), nil
271 +}
272 +
273 type RDClient struct {
274 mu sync.Mutex
275
@@ -264,12 +317,29 @@ func NewClient(opt ...Option) (*RDClient, error) {
317 // Initialize relays from bootstrap servers
318 var connectionErrors []error
319 for _, server := range config.BootstrapServers {
267 - err := client.AddRelay(server, config.Dialer)
320 + normalized, err := normalizeBootstrapServer(server)
321 + if err != nil {
322 + log.Error().
323 + Err(err).
324 + Str("server", server).
325 + Msg("[SDK] Invalid bootstrap server")
326 + connectionErrors = append(connectionErrors, err)
327 + continue
328 + }
329 +
330 + err = client.AddRelay(normalized, config.Dialer)
331 if err != nil {
269 - log.Error().Err(err).Str("server", server).Msg("[SDK] Failed to connect to bootstrap server")
332 + log.Error().
333 + Err(err).
334 + Str("server", normalized).
335 + Msg("[SDK] Failed to connect to bootstrap server")
336 connectionErrors = append(connectionErrors, err)
337 + continue
338 }
272 - log.Debug().Str("server", server).Msg("[SDK] Successfully connected to bootstrap server")
339 + log.Debug().
340 + Str("server_raw", server).
341 + Str("server", normalized).
342 + Msg("[SDK] Successfully connected to bootstrap server")
343 }
344
345 // If no relays were successfully connected, return an error
sdk/validation_test.go
+63
@@ -68,3 +68,66 @@ func TestIsURLSafeName(t *testing.T) {
68 })
69 }
70 }
71 +
72 +func TestNormalizeBootstrapServer(t *testing.T) {
73 + tests := []struct {
74 + name string
75 + input string
76 + want string
77 + shouldFail bool
78 + }{
79 + {
80 + name: "already ws",
81 + input: "ws://localhost:4017/relay",
82 + want: "ws://localhost:4017/relay",
83 + },
84 + {
85 + name: "already wss",
86 + input: "wss://localhost:4017/relay",
87 + want: "wss://localhost:4017/relay",
88 + },
89 + {
90 + name: "localhost with port",
91 + input: "localhost:4017",
92 + want: "wss://localhost:4017/relay",
93 + },
94 + {
95 + name: "domain without port",
96 + input: "example.com",
97 + want: "wss://example.com/relay",
98 + },
99 + {
100 + name: "http scheme",
101 + input: "http://example.com",
102 + want: "ws://example.com/relay",
103 + },
104 + {
105 + name: "https scheme",
106 + input: "https://example.com",
107 + want: "wss://example.com/relay",
108 + },
109 + {
110 + name: "empty",
111 + input: "",
112 + shouldFail: true,
113 + },
114 + }
115 +
116 + for _, tt := range tests {
117 + t.Run(tt.name, func(t *testing.T) {
118 + got, err := normalizeBootstrapServer(tt.input)
119 + if tt.shouldFail {
120 + if err == nil {
121 + t.Fatalf("normalizeBootstrapServer(%q) expected error, got nil", tt.input)
122 + }
123 + return
124 + }
125 + if err != nil {
126 + t.Fatalf("normalizeBootstrapServer(%q) unexpected error: %v", tt.input, err)
127 + }
128 + if got != tt.want {
129 + t.Fatalf("normalizeBootstrapServer(%q) = %q, want %q", tt.input, got, tt.want)
130 + }
131 + })
132 + }
133 +}