sdk: extract util helpers

Kim committed Nov 18, 2025 at 10:41 UTC 8b0e65ef73818bac019b528272bf9dd8c234cc94
3 files changed +134 -96
sdk/sdk.go
+1 -91
@@ -7,56 +7,18 @@ import (
7 "fmt"
8 "io"
9 "net"
10 - "net/url"
11 - "regexp"
10 "strings"
11 "sync"
12 "time"
13
16 - "github.com/gorilla/websocket"
14 "github.com/rs/zerolog/log"
15
16 "gosuda.org/portal/portal"
17 "gosuda.org/portal/portal/core/cryptoops"
18 "gosuda.org/portal/portal/core/proto/rdsec"
19 "gosuda.org/portal/portal/core/proto/rdverb"
23 - "gosuda.org/portal/portal/utils/wsstream"
20 )
21
26 -func NewCredential() *cryptoops.Credential {
27 - cred, err := cryptoops.NewCredential()
28 - if err != nil {
29 - log.Fatal().Err(err).Msg("Failed to create credential")
30 - }
31 - return cred
32 -}
33 -
34 -// URL-safe name validation regex
35 -// Allows: Unicode letters (\p{L}), Unicode numbers (\p{N}), hyphen (-), underscore (_)
36 -// This includes Korean (한글), Japanese (日本語), Chinese (中文), Arabic (العربية), etc.
37 -var urlSafeNameRegex = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
38 -
39 -// isURLSafeName checks if a name contains only URL-safe characters
40 -// Supports Unicode characters including Korean (한글), Japanese (日本語), Chinese (中文), etc.
41 -// Disallows: spaces, special characters like /, ?, &, =, %, etc.
42 -// Note: Browsers will automatically URL-encode non-ASCII characters (e.g., 한글 → %ED%95%9C%EA%B8%80)
43 -func isURLSafeName(name string) bool {
44 - if name == "" {
45 - return true // Empty name is allowed (will be treated as unnamed)
46 - }
47 - return urlSafeNameRegex.MatchString(name)
48 -}
49 -
50 -func webSocketDialer() func(context.Context, string) (io.ReadWriteCloser, error) {
51 - return func(ctx context.Context, url string) (io.ReadWriteCloser, error) {
52 - wsConn, _, err := websocket.DefaultDialer.Dial(url, nil)
53 - if err != nil {
54 - return nil, err
55 - }
56 - return &wsstream.WsStream{Conn: wsConn}, nil
57 - }
58 -}
59 -
22 type RDClientConfig struct {
23 BootstrapServers []string
24 Dialer func(context.Context, string) (io.ReadWriteCloser, error)
@@ -218,58 +180,6 @@ func WithHide(hide bool) MetadataOption {
180 }
181 }
182
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 -
183 type RDClient struct {
184 mu sync.Mutex
185
@@ -297,7 +207,7 @@ func NewClient(opt ...Option) (*RDClient, error) {
207 log.Debug().Msg("[SDK] Creating new RDClient")
208
209 config := &RDClientConfig{
300 - Dialer: webSocketDialer(),
210 + Dialer: newWebSocketDialer(),
211 HealthCheckInterval: 10 * time.Second,
212 ReconnectMaxRetries: 0,
213 ReconnectInterval: 5 * time.Second,
sdk/utils.go new
+101
@@ -0,0 +1,101 @@
1 +package sdk
2 +
3 +import (
4 + "context"
5 + "fmt"
6 + "io"
7 + "net/url"
8 + "regexp"
9 + "strings"
10 +
11 + "github.com/gorilla/websocket"
12 + "github.com/rs/zerolog/log"
13 +
14 + "gosuda.org/portal/portal/core/cryptoops"
15 + "gosuda.org/portal/portal/utils/wsstream"
16 +)
17 +
18 +func NewCredential() *cryptoops.Credential {
19 + cred, err := cryptoops.NewCredential()
20 + if err != nil {
21 + log.Fatal().Err(err).Msg("Failed to create credential")
22 + }
23 + return cred
24 +}
25 +
26 +// newWebSocketDialer returns a dialer that establishes WebSocket connections
27 +// and wraps them as io.ReadWriteCloser.
28 +func newWebSocketDialer() func(context.Context, string) (io.ReadWriteCloser, error) {
29 + return func(ctx context.Context, url string) (io.ReadWriteCloser, error) {
30 + wsConn, _, err := websocket.DefaultDialer.Dial(url, nil)
31 + if err != nil {
32 + return nil, err
33 + }
34 + return &wsstream.WsStream{Conn: wsConn}, nil
35 + }
36 +}
37 +
38 +// URL-safe name validation regex
39 +var urlSafeNameRegex = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
40 +
41 +// isURLSafeName checks if a name contains only URL-safe characters.
42 +// Disallows: spaces, special characters like /, ?, &, =, %, etc.
43 +// Note: Browsers will automatically URL-encode non-ASCII characters.
44 +func isURLSafeName(name string) bool {
45 + if name == "" {
46 + return true // Empty name is allowed (will be treated as unnamed)
47 + }
48 + return urlSafeNameRegex.MatchString(name)
49 +}
50 +
51 +// normalizeBootstrapServer takes various user-friendly server inputs and
52 +// converts them into a proper WebSocket URL.
53 +// Examples:
54 +// - "wss://localhost:4017/relay" -> unchanged
55 +// - "ws://localhost:4017/relay" -> unchanged
56 +// - "http://example.com" -> "ws://example.com/relay"
57 +// - "https://example.com" -> "wss://example.com/relay"
58 +// - "localhost:4017" -> "wss://localhost:4017/relay"
59 +// - "example.com" -> "wss://example.com/relay"
60 +func normalizeBootstrapServer(raw string) (string, error) {
61 + server := strings.TrimSpace(raw)
62 + if server == "" {
63 + return "", fmt.Errorf("bootstrap server is empty")
64 + }
65 +
66 + // Already a WebSocket URL
67 + if strings.HasPrefix(server, "ws://") || strings.HasPrefix(server, "wss://") {
68 + return server, nil
69 + }
70 +
71 + // HTTP/HTTPS -> WS/WSS with default /relay path
72 + if strings.HasPrefix(server, "http://") || strings.HasPrefix(server, "https://") {
73 + u, err := url.Parse(server)
74 + if err != nil {
75 + return "", fmt.Errorf("invalid bootstrap server %q: %w", raw, err)
76 + }
77 + switch u.Scheme {
78 + case "http":
79 + u.Scheme = "ws"
80 + case "https":
81 + u.Scheme = "wss"
82 + }
83 + if u.Path == "" || u.Path == "/" {
84 + u.Path = "/relay"
85 + }
86 + return u.String(), nil
87 + }
88 +
89 + // Bare host[:port][/path] -> assume WSS and /relay if no path
90 + u, err := url.Parse("wss://" + server)
91 + if err != nil {
92 + return "", fmt.Errorf("invalid bootstrap server %q: %w", raw, err)
93 + }
94 + if u.Host == "" {
95 + return "", fmt.Errorf("invalid bootstrap server %q: missing host", raw)
96 + }
97 + if u.Path == "" || u.Path == "/" {
98 + u.Path = "/relay"
99 + }
100 + return u.String(), nil
101 +}
sdk/utils_test.go renamed
+32 -5
@@ -26,10 +26,12 @@ func TestIsURLSafeName(t *testing.T) {
26 {"chinese", "中文服务", true},
27 {"arabic", "خدمة", true},
28 {"mixed languages", "Service-서비스-サービス", true},
29 - {"korean numbers", "서비스123", true},
29 + {"korean numbers", "서비스23", true},
30
31 // Invalid names
32 {"with space", "my service", false},
33 + {"with leading space", " service", false},
34 + {"with trailing space", "service ", false},
35 {"with slash", "my/service", false},
36 {"with dot", "my.service", false},
37 {"with colon", "my:service", false},
@@ -55,8 +57,8 @@ func TestIsURLSafeName(t *testing.T) {
57 {"with backtick", "my`service", false},
58 {"with less than", "my<service", false},
59 {"with greater than", "my>service", false},
58 - {"emoji", "my-service😀", false},
59 - {"with space korean", "한글 서비스", false},
60 + {"emoji", "my-service🚀", false},
61 + {"with space korean", "한 글서비스", false},
62 }
63
64 for _, tt := range tests {
@@ -97,20 +99,45 @@ func TestNormalizeBootstrapServer(t *testing.T) {
99 want: "wss://example.com/relay",
100 },
101 {
100 - name: "http scheme",
102 + name: "http scheme without path",
103 input: "http://example.com",
104 want: "ws://example.com/relay",
105 },
106 {
105 - name: "https scheme",
107 + name: "https scheme without path",
108 input: "https://example.com",
109 want: "wss://example.com/relay",
110 },
111 + {
112 + name: "http scheme with path",
113 + input: "http://example.com/custom",
114 + want: "ws://example.com/custom",
115 + },
116 + {
117 + name: "https scheme with path",
118 + input: "https://example.com/custom",
119 + want: "wss://example.com/custom",
120 + },
121 + {
122 + name: "bare host with path",
123 + input: "example.com/custom",
124 + want: "wss://example.com/custom",
125 + },
126 {
127 name: "empty",
128 input: "",
129 shouldFail: true,
130 },
131 + {
132 + name: "whitespace only",
133 + input: " ",
134 + shouldFail: true,
135 + },
136 + {
137 + name: "missing host",
138 + input: "/relay",
139 + shouldFail: true,
140 + },
141 }
142
143 for _, tt := range tests {