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 +}