cmd: extract util functions to sdk
Kim committed
Nov 18, 2025 at 11:58 UTC
46b2e44a065e074a6d0992355a9e16ed543ae556
7 files changed
+189
-75
cmd/demo-app/main.go
+1
-7
@@ -48,15 +48,9 @@ func main() {
48
}
49
}
50
51
-var upgrader = websocket.Upgrader{
52
- CheckOrigin: func(r *http.Request) bool {
53
- return true
54
- },
55
-}
56
-
51
// handleWS is a minimal WebSocket echo handler to verify bidirectional connectivity.
52
func handleWS(w http.ResponseWriter, r *http.Request) {
59
- conn, err := upgrader.Upgrade(w, r, nil)
53
+ conn, err := sdk.DefaultWebSocketUpgrader.Upgrade(w, r, nil)
54
if err != nil {
55
log.Error().Err(err).Msg("upgrade websocket")
56
return
cmd/portal-tunnel/main.go
+1
-20
@@ -121,7 +121,7 @@ func runExposeWithConfig() error {
121
}
122
123
func runExposeWithFlags() error {
124
- relayURLs := parseCommaSeparatedURLs(flagRelayURLs)
124
+ relayURLs := sdk.ParseURLs(flagRelayURLs)
125
if len(relayURLs) == 0 {
126
return fmt.Errorf("--relay must include at least one non-empty URL when --config is not provided")
127
}
@@ -280,22 +280,3 @@ func runServiceTunnel(ctx context.Context, relayDir *RelayDirectory, service *Se
280
}(relayConn)
281
}
282
}
283
-
284
-func parseCommaSeparatedURLs(raw string) []string {
285
- raw = strings.TrimSpace(raw)
286
- if raw == "" {
287
- return nil
288
- }
289
-
290
- parts := strings.Split(raw, ",")
291
- out := make([]string, 0, len(parts))
292
-
293
- for _, p := range parts {
294
- p = strings.TrimSpace(p)
295
- if p != "" {
296
- out = append(out, p)
297
- }
298
- }
299
-
300
- return out
301
-}
cmd/webclient/main_js.go
+1
-20
@@ -8,7 +8,6 @@ import (
8
"encoding/json"
9
"fmt"
10
"io"
11
- "mime"
11
"net"
12
"net/http"
13
"net/url"
@@ -344,24 +343,6 @@ func (c *WSConnection) Close() {
343
})
344
}
345
347
-// IsHTMLContentType checks if the Content-Type header indicates HTML content
348
-// It properly handles media type parsing with parameters like charset
349
-func IsHTMLContentType(contentType string) bool {
350
- if contentType == "" {
351
- return false
352
- }
353
-
354
- // Parse the media type and parameters
355
- mediaType, _, err := mime.ParseMediaType(contentType)
356
- if err != nil {
357
- // If parsing fails, do a simple case-insensitive check for "text/html"
358
- return strings.HasPrefix(strings.ToLower(contentType), "text/html")
359
- }
360
-
361
- // Check if the media type is HTML
362
- return mediaType == "text/html"
363
-}
364
-
346
func getLeaseID(hostname string) string {
347
// First, decode URL-encoded characters (e.g., %ED%8E%98%EC%9D%B8%ED%8A%B8 -> 페인트)
348
decoded, err := url.QueryUnescape(hostname)
@@ -412,7 +393,7 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
393
w.Header()[key] = value
394
}
395
415
- if IsHTMLContentType(resp.Header.Get("Content-Type")) {
396
+ if sdk.IsHTMLContentType(resp.Header.Get("Content-Type")) {
397
w.WriteHeader(resp.StatusCode)
398
body, err := io.ReadAll(resp.Body)
399
if err != nil {
sdk/client.go
+2
-2
@@ -33,7 +33,7 @@ func NewClient(opt ...ClientOption) (*Client, error) {
33
log.Debug().Msg("[SDK] Creating new Client")
34
35
config := &ClientConfig{
36
- Dialer: newWebSocketDialer(),
36
+ Dialer: NewWebSocketDialer(),
37
HealthCheckInterval: 10 * time.Second,
38
ReconnectMaxRetries: 0,
39
ReconnectInterval: 5 * time.Second,
@@ -53,7 +53,7 @@ func NewClient(opt ...ClientOption) (*Client, error) {
53
// Initialize relays from bootstrap servers
54
var connectionErrors []error
55
for _, server := range config.BootstrapServers {
56
- normalized, err := normalizeBootstrapServer(server)
56
+ normalized, err := NormalizePortalURL(server)
57
if err != nil {
58
log.Error().
59
Err(err).
sdk/client_e2e_test.go
+6
-20
@@ -11,7 +11,6 @@ import (
11
"testing"
12
"time"
13
14
- "github.com/gorilla/websocket"
14
"github.com/rs/zerolog"
15
"github.com/rs/zerolog/log"
16
"github.com/stretchr/testify/assert"
@@ -19,7 +18,6 @@ import (
18
19
"gosuda.org/portal/portal"
20
"gosuda.org/portal/portal/core/cryptoops"
22
- "gosuda.org/portal/portal/utils/wsstream"
21
)
22
23
func init() {
@@ -50,16 +48,12 @@ func TestE2E_ClientToAppThroughRelay(t *testing.T) {
48
relayMux := http.NewServeMux()
49
relayMux.HandleFunc("/relay", func(w http.ResponseWriter, r *http.Request) {
50
log.Debug().Str("remote", r.RemoteAddr).Msg("[TEST] Relay server accepting WebSocket connection")
53
- upgrader := websocket.Upgrader{
54
- CheckOrigin: func(r *http.Request) bool { return true },
55
- }
56
- ws, err := upgrader.Upgrade(w, r, nil)
51
+ stream, _, err := UpgradeToWSStream(w, r, nil)
52
if err != nil {
53
log.Error().Err(err).Msg("[TEST] Failed to upgrade WebSocket")
54
return
55
}
61
- wsConn := &wsstream.WsStream{Conn: ws}
62
- if err := relayServer.HandleConnection(wsConn); err != nil {
56
+ if err := relayServer.HandleConnection(stream); err != nil {
57
log.Error().Err(err).Msg("[TEST] Relay server error handling connection")
58
}
59
})
@@ -217,15 +211,11 @@ func TestE2E_MultipleConnections(t *testing.T) {
211
relayAddr := "127.0.0.1:14018"
212
relayMux := http.NewServeMux()
213
relayMux.HandleFunc("/relay", func(w http.ResponseWriter, r *http.Request) {
220
- upgrader := websocket.Upgrader{
221
- CheckOrigin: func(r *http.Request) bool { return true },
222
- }
223
- ws, err := upgrader.Upgrade(w, r, nil)
214
+ stream, _, err := UpgradeToWSStream(w, r, nil)
215
if err != nil {
216
return
217
}
227
- wsConn := &wsstream.WsStream{Conn: ws}
228
- relayServer.HandleConnection(wsConn)
218
+ relayServer.HandleConnection(stream)
219
})
220
221
relayHTTPServer := &http.Server{
@@ -361,15 +351,11 @@ func TestE2E_ConnectionTimeout(t *testing.T) {
351
relayAddr := "127.0.0.1:14019"
352
relayMux := http.NewServeMux()
353
relayMux.HandleFunc("/relay", func(w http.ResponseWriter, r *http.Request) {
364
- upgrader := websocket.Upgrader{
365
- CheckOrigin: func(r *http.Request) bool { return true },
366
- }
367
- ws, err := upgrader.Upgrade(w, r, nil)
354
+ stream, _, err := UpgradeToWSStream(w, r, nil)
355
if err != nil {
356
return
357
}
371
- wsConn := &wsstream.WsStream{Conn: ws}
372
- relayServer.HandleConnection(wsConn)
358
+ relayServer.HandleConnection(stream)
359
})
360
361
relayHTTPServer := &http.Server{
sdk/utils.go
+107
-4
@@ -4,6 +4,8 @@ import (
4
"context"
5
"fmt"
6
"io"
7
+ "mime"
8
+ "net/http"
9
"net/url"
10
"regexp"
11
"strings"
@@ -23,9 +25,9 @@ func NewCredential() *cryptoops.Credential {
25
return cred
26
}
27
26
-// newWebSocketDialer returns a dialer that establishes WebSocket connections
28
+// NewWebSocketDialer returns a dialer that establishes WebSocket connections
29
// and wraps them as io.ReadWriteCloser.
28
-func newWebSocketDialer() func(context.Context, string) (io.ReadWriteCloser, error) {
30
+func NewWebSocketDialer() func(context.Context, string) (io.ReadWriteCloser, error) {
31
return func(ctx context.Context, url string) (io.ReadWriteCloser, error) {
32
wsConn, _, err := websocket.DefaultDialer.Dial(url, nil)
33
if err != nil {
@@ -35,6 +37,25 @@ func newWebSocketDialer() func(context.Context, string) (io.ReadWriteCloser, err
37
}
38
}
39
40
+// DefaultWebSocketUpgrader provides a permissive upgrader used across cmd binaries
41
+var DefaultWebSocketUpgrader = websocket.Upgrader{
42
+ CheckOrigin: func(r *http.Request) bool { return true },
43
+}
44
+
45
+// UpgradeWebSocket upgrades the request/response to a WebSocket connection using DefaultWebSocketUpgrader
46
+func UpgradeWebSocket(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*websocket.Conn, error) {
47
+ return DefaultWebSocketUpgrader.Upgrade(w, r, responseHeader)
48
+}
49
+
50
+// UpgradeToWSStream upgrades HTTP to WebSocket and wraps it as io.ReadWriteCloser
51
+func UpgradeToWSStream(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (io.ReadWriteCloser, *websocket.Conn, error) {
52
+ wsConn, err := UpgradeWebSocket(w, r, responseHeader)
53
+ if err != nil {
54
+ return nil, nil, err
55
+ }
56
+ return &wsstream.WsStream{Conn: wsConn}, wsConn, nil
57
+}
58
+
59
// URL-safe name validation regex
60
var urlSafeNameRegex = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
61
@@ -48,7 +69,7 @@ func isURLSafeName(name string) bool {
69
return urlSafeNameRegex.MatchString(name)
70
}
71
51
-// normalizeBootstrapServer takes various user-friendly server inputs and
72
+// NormalizePortalURL takes various user-friendly server inputs and
73
// converts them into a proper WebSocket URL.
74
// Examples:
75
// - "wss://localhost:4017/relay" -> unchanged
@@ -57,7 +78,7 @@ func isURLSafeName(name string) bool {
78
// - "https://example.com" -> "wss://example.com/relay"
79
// - "localhost:4017" -> "wss://localhost:4017/relay"
80
// - "example.com" -> "wss://example.com/relay"
60
-func normalizeBootstrapServer(raw string) (string, error) {
81
+func NormalizePortalURL(raw string) (string, error) {
82
server := strings.TrimSpace(raw)
83
if server == "" {
84
return "", fmt.Errorf("bootstrap server is empty")
@@ -99,3 +120,85 @@ func normalizeBootstrapServer(raw string) (string, error) {
120
}
121
return u.String(), nil
122
}
123
+
124
+// ParseURLs splits a comma-separated string into a list of trimmed, non-empty URLs.
125
+func ParseURLs(raw string) []string {
126
+ raw = strings.TrimSpace(raw)
127
+ if raw == "" {
128
+ return nil
129
+ }
130
+ parts := strings.Split(raw, ",")
131
+ out := make([]string, 0, len(parts))
132
+ for _, p := range parts {
133
+ p = strings.TrimSpace(p)
134
+ if p != "" {
135
+ out = append(out, p)
136
+ }
137
+ }
138
+ return out
139
+}
140
+
141
+// GetContentType returns the MIME type for a file extension
142
+func GetContentType(ext string) string {
143
+ switch ext {
144
+ case ".html":
145
+ return "text/html; charset=utf-8"
146
+ case ".js":
147
+ return "application/javascript"
148
+ case ".json":
149
+ return "application/json"
150
+ case ".wasm":
151
+ return "application/wasm"
152
+ case ".css":
153
+ return "text/css"
154
+ case ".mp4":
155
+ return "video/mp4"
156
+ case ".svg":
157
+ return "image/svg+xml"
158
+ case ".png":
159
+ return "image/png"
160
+ case ".ico":
161
+ return "image/x-icon"
162
+ default:
163
+ return ""
164
+ }
165
+}
166
+
167
+// MatchesWildcardPattern checks if a host matches a wildcard pattern (e.g., *.localhost:4017)
168
+func MatchesWildcardPattern(host, pattern string) bool {
169
+ if strings.HasPrefix(pattern, "*.") {
170
+ suffix := strings.TrimPrefix(pattern, "*")
171
+ return strings.HasSuffix(host, suffix)
172
+ }
173
+ return host == pattern
174
+}
175
+
176
+// IsHexString reports whether s contains only hexadecimal characters
177
+func IsHexString(s string) bool {
178
+ for _, c := range s {
179
+ if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
180
+ return false
181
+ }
182
+ }
183
+ return true
184
+}
185
+
186
+// IsHTMLContentType checks if the Content-Type header indicates HTML content
187
+// It properly handles media type parsing with parameters like charset
188
+func IsHTMLContentType(contentType string) bool {
189
+ if contentType == "" {
190
+ return false
191
+ }
192
+ mediaType, _, err := mime.ParseMediaType(contentType)
193
+ if err != nil {
194
+ return strings.HasPrefix(strings.ToLower(contentType), "text/html")
195
+ }
196
+ return mediaType == "text/html"
197
+}
198
+
199
+// SetCORSHeaders sets permissive CORS headers for GET/OPTIONS and common headers
200
+func SetCORSHeaders(w http.ResponseWriter) {
201
+ w.Header().Set("Access-Control-Allow-Origin", "*")
202
+ w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
203
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Accept, Accept-Encoding")
204
+}
sdk/utils_test.go
+71
-2
@@ -73,7 +73,7 @@ func TestIsURLSafeName(t *testing.T) {
73
}
74
}
75
76
-func TestNormalizeBootstrapServer(t *testing.T) {
76
+func TestNormalizePortalURL(t *testing.T) {
77
tests := []struct {
78
name string
79
input string
@@ -144,7 +144,7 @@ func TestNormalizeBootstrapServer(t *testing.T) {
144
145
for _, tt := range tests {
146
t.Run(tt.name, func(t *testing.T) {
147
- got, err := normalizeBootstrapServer(tt.input)
147
+ got, err := NormalizePortalURL(tt.input)
148
if tt.shouldFail {
149
assert.Error(t, err, "normalizeBootstrapServer(%q) expected error", tt.input)
150
return
@@ -154,3 +154,72 @@ func TestNormalizeBootstrapServer(t *testing.T) {
154
})
155
}
156
}
157
+
158
+func TestParseURLs(t *testing.T) {
159
+ tests := []struct {
160
+ name string
161
+ input string
162
+ want []string
163
+ }{
164
+ {"empty", "", nil},
165
+ {"spaces only", " ", nil},
166
+ {"single", "ws://a", []string{"ws://a"}},
167
+ {"trim spaces", " ws://a , wss://b ", []string{"ws://a", "wss://b"}},
168
+ {"ignore empties", ",,ws://a,,wss://b,,", []string{"ws://a", "wss://b"}},
169
+ {"three", "a,b,c", []string{"a", "b", "c"}},
170
+ }
171
+
172
+ for _, tt := range tests {
173
+ t.Run(tt.name, func(t *testing.T) {
174
+ got := ParseURLs(tt.input)
175
+ assert.Equal(t, tt.want, got)
176
+ })
177
+ }
178
+}
179
+
180
+func TestIsHTMLContentType(t *testing.T) {
181
+ assert.True(t, IsHTMLContentType("text/html"))
182
+ assert.True(t, IsHTMLContentType("text/html; charset=utf-8"))
183
+ assert.True(t, IsHTMLContentType("TEXT/HTML; CHARSET=UTF-8"))
184
+ // Fallback path (parse error) with html prefix
185
+ assert.True(t, IsHTMLContentType("text/html; bad==value"))
186
+ assert.False(t, IsHTMLContentType("application/json"))
187
+ assert.False(t, IsHTMLContentType(""))
188
+}
189
+
190
+func TestGetContentType(t *testing.T) {
191
+ cases := map[string]string{
192
+ ".html": "text/html; charset=utf-8",
193
+ ".js": "application/javascript",
194
+ ".json": "application/json",
195
+ ".wasm": "application/wasm",
196
+ ".css": "text/css",
197
+ ".mp4": "video/mp4",
198
+ ".svg": "image/svg+xml",
199
+ ".png": "image/png",
200
+ ".ico": "image/x-icon",
201
+ ".bin": "",
202
+ "": "",
203
+ }
204
+ for ext, want := range cases {
205
+ got := GetContentType(ext)
206
+ assert.Equal(t, want, got, "ext=%q", ext)
207
+ }
208
+}
209
+
210
+func TestMatchesWildcardPattern(t *testing.T) {
211
+ // Wildcard pattern
212
+ assert.True(t, MatchesWildcardPattern("app.localhost:4017", "*.localhost:4017"))
213
+ assert.True(t, MatchesWildcardPattern("x.y.localhost:4017", "*.localhost:4017"))
214
+ assert.False(t, MatchesWildcardPattern("localhost:4017", "*.localhost:4017"))
215
+ assert.True(t, MatchesWildcardPattern("exact.host", "exact.host"))
216
+ assert.False(t, MatchesWildcardPattern("sub.exact.host", "exact.host"))
217
+}
218
+
219
+func TestIsHexString(t *testing.T) {
220
+ assert.True(t, IsHexString("0123456789abcdef"))
221
+ assert.True(t, IsHexString("ABCDEF"))
222
+ assert.True(t, IsHexString(""), "empty string is considered hex")
223
+ assert.False(t, IsHexString("g"))
224
+ assert.False(t, IsHexString("xyz"))
225
+}