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 {