sdk: export utils to common package

Kim committed Nov 19, 2025 at 14:19 UTC f433a23e59c591a0df9ebc19b60b8af610d6c1db
9 files changed +53 -49
cmd/demo-app/main.go
+2 -1
@@ -17,6 +17,7 @@ import (
17 "github.com/rs/zerolog/log"
18
19 "gosuda.org/portal/sdk"
20 + "gosuda.org/portal/utils"
21 )
22
23 //go:embed static
@@ -50,7 +51,7 @@ func main() {
51
52 // handleWS is a minimal WebSocket echo handler to verify bidirectional connectivity.
53 func handleWS(w http.ResponseWriter, r *http.Request) {
53 - conn, err := sdk.UpgradeWebSocket(w, r, nil)
54 + conn, err := utils.UpgradeWebSocket(w, r, nil)
55 if err != nil {
56 log.Error().Err(err).Msg("upgrade websocket")
57 return
cmd/portal-tunnel/main.go
+2 -1
@@ -14,6 +14,7 @@ import (
14
15 "github.com/rs/zerolog/log"
16 "gosuda.org/portal/sdk"
17 + "gosuda.org/portal/utils"
18 )
19
20 var (
@@ -121,7 +122,7 @@ func runExposeWithConfig() error {
122 }
123
124 func runExposeWithFlags() error {
124 - relayURLs := sdk.ParseURLs(flagRelayURLs)
125 + relayURLs := utils.ParseURLs(flagRelayURLs)
126 if len(relayURLs) == 0 {
127 return fmt.Errorf("--relay must include at least one non-empty URL when --config is not provided")
128 }
cmd/relay-server/main.go
+4 -3
@@ -15,6 +15,7 @@ import (
15
16 "gosuda.org/portal/portal"
17 "gosuda.org/portal/sdk"
18 + "gosuda.org/portal/utils"
19 )
20
21 var (
@@ -37,11 +38,11 @@ func main() {
38 }
39 defaultSubdomain := os.Getenv("PORTAL_SUBDOMAIN_URL")
40 if defaultSubdomain == "" {
40 - defaultSubdomain = sdk.DefaultSubdomainPattern(defaultPortalURL)
41 + defaultSubdomain = utils.DefaultSubdomainPattern(defaultPortalURL)
42 }
43 defaultBootstraps := os.Getenv("BOOTSTRAP_URIS")
44 if defaultBootstraps == "" {
44 - defaultBootstraps = sdk.DefaultBootstrapFrom(defaultPortalURL)
45 + defaultBootstraps = utils.DefaultBootstrapFrom(defaultPortalURL)
46 }
47
48 var flagBootstrapsCSV string
@@ -54,7 +55,7 @@ func main() {
55 flag.IntVar(&flagLeaseBPS, "lease-bps", 0, "default bytes-per-second limit per lease (0 = unlimited)")
56 flag.Parse()
57
57 - flagBootstraps = sdk.ParseURLs(flagBootstrapsCSV)
58 + flagBootstraps = utils.ParseURLs(flagBootstrapsCSV)
59 if err := runServer(); err != nil {
60 log.Fatal().Err(err).Msg("execute root command")
61 }
cmd/relay-server/serve.go
+16 -16
@@ -10,7 +10,7 @@ import (
10
11 "github.com/rs/zerolog/log"
12 "gosuda.org/portal/portal"
13 - "gosuda.org/portal/sdk"
13 + "gosuda.org/portal/utils"
14 )
15
16 func serveAsset(mux *http.ServeMux, route, assetPath, contentType string) {
@@ -32,7 +32,7 @@ func serveAsset(mux *http.ServeMux, route, assetPath, contentType string) {
32
33 // servePortalHTMLWithSSR serves portal.html with SSR data injection
34 func servePortalHTMLWithSSR(w http.ResponseWriter, r *http.Request, serv *portal.RelayServer) {
35 - sdk.SetCORSHeaders(w)
35 + utils.SetCORSHeaders(w)
36
37 // Read portal.html from embedded FS
38 fullPath := path.Join("dist", "app", "portal.html")
@@ -88,7 +88,7 @@ func servePortalStaticFile(w http.ResponseWriter, r *http.Request, filePath stri
88 // Check if this is a content-addressed WASM file
89 if strings.HasSuffix(filePath, ".wasm") {
90 hash := strings.TrimSuffix(filePath, ".wasm")
91 - if sdk.IsHexString(hash) {
91 + if utils.IsHexString(hash) {
92 serveCompressedWasm(w, r, filePath)
93 return
94 }
@@ -108,7 +108,7 @@ func serveAppStatic(w http.ResponseWriter, r *http.Request, appPath string, serv
108 return
109 }
110
111 - sdk.SetCORSHeaders(w)
111 + utils.SetCORSHeaders(w)
112
113 // If path is empty or "/", serve portal.html with SSR
114 if appPath == "" || appPath == "/" {
@@ -128,7 +128,7 @@ func serveAppStatic(w http.ResponseWriter, r *http.Request, appPath string, serv
128
129 // Set content type based on extension
130 ext := path.Ext(appPath)
131 - contentType := sdk.GetContentType(ext)
131 + contentType := utils.GetContentType(ext)
132 if contentType != "" {
133 w.Header().Set("Content-Type", contentType)
134 }
@@ -171,7 +171,7 @@ func initWasmCache() error {
171 // Look for content-addressed WASM files: <hex>.wasm.br
172 if strings.HasSuffix(name, ".wasm.br") {
173 hash := strings.TrimSuffix(name, ".wasm.br")
174 - if sdk.IsHexString(hash) {
174 + if utils.IsHexString(hash) {
175 fullPath := path.Join("dist", "wasm", name)
176 // Cache under the URL path (<hash>.wasm) while reading the
177 // brotli-compressed artifact (<hash>.wasm.br) from embed.FS.
@@ -192,7 +192,7 @@ func initWasmCache() error {
192 func cacheWasmFile(name, fullPath string) error {
193 // Verify name looks like a hex hash (name is <hash>.wasm).
194 hashHex := strings.TrimSuffix(name, ".wasm")
195 - if !sdk.IsHexString(hashHex) {
195 + if !utils.IsHexString(hashHex) {
196 log.Warn().Str("file", name).Msg("WASM file name is not a valid SHA256 hex string")
197 }
198
@@ -240,7 +240,7 @@ func serveCompressedWasm(w http.ResponseWriter, r *http.Request, filePath string
240 }
241
242 // Serve uncompressed WASM
243 - sdk.SetCORSHeaders(w)
243 + utils.SetCORSHeaders(w)
244 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
245 w.Header().Set("Content-Type", "application/wasm")
246 w.Header().Set("Content-Length", strconv.Itoa(len(data)))
@@ -254,7 +254,7 @@ func serveCompressedWasm(w http.ResponseWriter, r *http.Request, filePath string
254 }
255
256 // Set immutable cache headers for content-addressed files
257 - sdk.SetCORSHeaders(w)
257 + utils.SetCORSHeaders(w)
258 w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
259 w.Header().Set("Content-Type", "application/wasm")
260
@@ -324,7 +324,7 @@ func servePortalStatic(w http.ResponseWriter, r *http.Request) {
324
325 // serveStaticFile reads and serves a file from the static directory
326 func serveStaticFile(w http.ResponseWriter, r *http.Request, filePath string, contentType string) {
327 - sdk.SetCORSHeaders(w)
327 + utils.SetCORSHeaders(w)
328
329 fullPath := path.Join("dist", "wasm", filePath)
330 data, err := distFS.ReadFile(fullPath)
@@ -339,7 +339,7 @@ func serveStaticFile(w http.ResponseWriter, r *http.Request, filePath string, co
339 w.Header().Set("Content-Type", contentType)
340 } else {
341 ext := path.Ext(filePath)
342 - ct := sdk.GetContentType(ext)
342 + ct := utils.GetContentType(ext)
343 if ct != "" {
344 w.Header().Set("Content-Type", ct)
345 }
@@ -357,7 +357,7 @@ func serveStaticFile(w http.ResponseWriter, r *http.Request, filePath string, co
357 // serveStaticFileWithFallback reads and serves a file from the static directory
358 // If the file is not found, it falls back to portal.html for SPA routing
359 func serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, filePath string, contentType string) {
360 - sdk.SetCORSHeaders(w)
360 + utils.SetCORSHeaders(w)
361
362 fullPath := path.Join("dist", "wasm", filePath)
363 data, err := distFS.ReadFile(fullPath)
@@ -374,7 +374,7 @@ func serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, filePat
374 w.Header().Set("Content-Type", contentType)
375 } else {
376 ext := path.Ext(filePath)
377 - ct := sdk.GetContentType(ext)
377 + ct := utils.GetContentType(ext)
378 if ct != "" {
379 w.Header().Set("Content-Type", ct)
380 }
@@ -391,7 +391,7 @@ func serveStaticFileWithFallback(w http.ResponseWriter, r *http.Request, filePat
391
392 // serveDynamicManifest generates and serves manifest.json dynamically
393 func serveDynamicManifest(w http.ResponseWriter, _ *http.Request) {
394 - sdk.SetCORSHeaders(w)
394 + utils.SetCORSHeaders(w)
395
396 // Find the content-addressed WASM file
397 wasmCacheMu.RLock()
@@ -416,7 +416,7 @@ func serveDynamicManifest(w http.ResponseWriter, _ *http.Request) {
416 // Look for content-addressed WASM files: <hex>.wasm.br
417 if strings.HasSuffix(name, ".wasm.br") {
418 hash := strings.TrimSuffix(name, ".wasm.br")
419 - if sdk.IsHexString(hash) {
419 + if utils.IsHexString(hash) {
420 wasmHash = hash
421 wasmFile = hash + ".wasm"
422 break
@@ -459,7 +459,7 @@ func serveDynamicManifest(w http.ResponseWriter, _ *http.Request) {
459
460 // serveDynamicServiceWorker serves service-worker.js with injected manifest and config
461 func serveDynamicServiceWorker(w http.ResponseWriter, r *http.Request) {
462 - sdk.SetCORSHeaders(w)
462 + utils.SetCORSHeaders(w)
463
464 // Read the service-worker.js template
465 fullPath := path.Join("dist", "wasm", "service-worker.js")
cmd/relay-server/view.go
+8 -7
@@ -13,6 +13,7 @@ import (
13
14 "gosuda.org/portal/portal"
15 "gosuda.org/portal/sdk"
16 + "gosuda.org/portal/utils"
17 )
18
19 //go:embed dist/*
@@ -39,7 +40,7 @@ func serveHTTP(addr string, serv *portal.RelayServer, nodeID string, bootstraps
40
41 // Portal app assets (JS, CSS, etc.) - served from /app/
42 appMux.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
42 - sdk.SetCORSHeaders(w)
43 + utils.SetCORSHeaders(w)
44 if r.Method == http.MethodOptions {
45 w.WriteHeader(http.StatusOK)
46 return
@@ -50,7 +51,7 @@ func serveHTTP(addr string, serv *portal.RelayServer, nodeID string, bootstraps
51
52 // Portal frontend files (for unified caching)
53 appMux.HandleFunc("/frontend/", func(w http.ResponseWriter, r *http.Request) {
53 - sdk.SetCORSHeaders(w)
54 + utils.SetCORSHeaders(w)
55 if r.Method == http.MethodOptions {
56 w.WriteHeader(http.StatusOK)
57 return
@@ -71,7 +72,7 @@ func serveHTTP(addr string, serv *portal.RelayServer, nodeID string, bootstraps
72 return
73 }
74
74 - stream, wsConn, err := sdk.UpgradeToWSStream(w, r, nil)
75 + stream, wsConn, err := utils.UpgradeToWSStream(w, r, nil)
76 if err != nil {
77 log.Error().Err(err).Msg("[server] websocket upgrade failed")
78 return
@@ -100,7 +101,7 @@ func serveHTTP(addr string, serv *portal.RelayServer, nodeID string, bootstraps
101
102 // Static file handler for /frontend/ (for unified caching)
103 portalMux.HandleFunc("/frontend/", func(w http.ResponseWriter, r *http.Request) {
103 - sdk.SetCORSHeaders(w)
104 + utils.SetCORSHeaders(w)
105 if r.Method == http.MethodOptions {
106 w.WriteHeader(http.StatusOK)
107 return
@@ -120,7 +121,7 @@ func serveHTTP(addr string, serv *portal.RelayServer, nodeID string, bootstraps
121
122 // Root and SPA fallback for portal subdomains
123 portalMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
123 - sdk.SetCORSHeaders(w)
124 + utils.SetCORSHeaders(w)
125 if r.Method == http.MethodOptions {
126 w.WriteHeader(http.StatusOK)
127 return
@@ -137,7 +138,7 @@ func serveHTTP(addr string, serv *portal.RelayServer, nodeID string, bootstraps
138 handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
139 // Route subdomain requests (e.g., *.example.com) to portalMux
140 // and everything else to the app UI mux.
140 - if sdk.IsSubdomain(flagPortalSubdomainURL, r.Host) {
141 + if utils.IsSubdomain(flagPortalSubdomainURL, r.Host) {
142 portalMux.ServeHTTP(w, r)
143 } else {
144 appMux.ServeHTTP(w, r)
@@ -271,7 +272,7 @@ func convertLeaseEntriesToRows(serv *portal.RelayServer) []leaseRow {
272 if base == "" {
273 base = flagPortalURL
274 }
274 - link := fmt.Sprintf("//%s.%s/", lease.Name, sdk.StripWildCard(sdk.StripScheme(base)))
275 + link := fmt.Sprintf("//%s.%s/", lease.Name, utils.StripWildCard(utils.StripScheme(base)))
276
277 row := leaseRow{
278 Peer: identityID,
sdk/client.go
+12 -3
@@ -15,8 +15,17 @@ import (
15 "gosuda.org/portal/portal/core/cryptoops"
16 "gosuda.org/portal/portal/core/proto/rdsec"
17 "gosuda.org/portal/portal/core/proto/rdverb"
18 + "gosuda.org/portal/utils"
19 )
20
21 +func NewCredential() *cryptoops.Credential {
22 + cred, err := cryptoops.NewCredential()
23 + if err != nil {
24 + log.Fatal().Err(err).Msg("Failed to create credential")
25 + }
26 + return cred
27 +}
28 +
29 type Client struct {
30 config *ClientConfig
31 mu sync.Mutex
@@ -33,7 +42,7 @@ func NewClient(opt ...ClientOption) (*Client, error) {
42 log.Debug().Msg("[SDK] Creating new Client")
43
44 config := &ClientConfig{
36 - Dialer: NewWebSocketDialer(),
45 + Dialer: utils.NewWebSocketDialer(),
46 HealthCheckInterval: 10 * time.Second,
47 ReconnectMaxRetries: 0,
48 ReconnectInterval: 5 * time.Second,
@@ -53,7 +62,7 @@ func NewClient(opt ...ClientOption) (*Client, error) {
62 // Initialize relays from bootstrap servers
63 var connectionErrors []error
64 for _, server := range config.BootstrapServers {
56 - normalized, err := NormalizePortalURL(server)
65 + normalized, err := utils.NormalizePortalURL(server)
66 if err != nil {
67 log.Error().
68 Err(err).
@@ -170,7 +179,7 @@ func (g *Client) Listen(cred *cryptoops.Credential, name string, alpns []string,
179 Msg("[SDK] Creating listener")
180
181 // Validate name is URL-safe
173 - if !isURLSafeName(name) {
182 + if !utils.IsURLSafeName(name) {
183 log.Error().
184 Str("name", name).
185 Msg("[SDK] Lease name contains invalid characters")
sdk/client_e2e_test.go
+4 -3
@@ -18,6 +18,7 @@ import (
18
19 "gosuda.org/portal/portal"
20 "gosuda.org/portal/portal/core/cryptoops"
21 + "gosuda.org/portal/utils"
22 )
23
24 func init() {
@@ -48,7 +49,7 @@ func TestE2E_ClientToAppThroughRelay(t *testing.T) {
49 relayMux := http.NewServeMux()
50 relayMux.HandleFunc("/relay", func(w http.ResponseWriter, r *http.Request) {
51 log.Debug().Str("remote", r.RemoteAddr).Msg("[TEST] Relay server accepting WebSocket connection")
51 - stream, _, err := UpgradeToWSStream(w, r, nil)
52 + stream, _, err := utils.UpgradeToWSStream(w, r, nil)
53 if err != nil {
54 log.Error().Err(err).Msg("[TEST] Failed to upgrade WebSocket")
55 return
@@ -211,7 +212,7 @@ func TestE2E_MultipleConnections(t *testing.T) {
212 relayAddr := "127.0.0.1:14018"
213 relayMux := http.NewServeMux()
214 relayMux.HandleFunc("/relay", func(w http.ResponseWriter, r *http.Request) {
214 - stream, _, err := UpgradeToWSStream(w, r, nil)
215 + stream, _, err := utils.UpgradeToWSStream(w, r, nil)
216 if err != nil {
217 return
218 }
@@ -351,7 +352,7 @@ func TestE2E_ConnectionTimeout(t *testing.T) {
352 relayAddr := "127.0.0.1:14019"
353 relayMux := http.NewServeMux()
354 relayMux.HandleFunc("/relay", func(w http.ResponseWriter, r *http.Request) {
354 - stream, _, err := UpgradeToWSStream(w, r, nil)
355 + stream, _, err := utils.UpgradeToWSStream(w, r, nil)
356 if err != nil {
357 return
358 }
utils/utils.go renamed
+3 -13
@@ -1,4 +1,4 @@
1 -package sdk
1 +package utils
2
3 import (
4 "context"
@@ -11,20 +11,10 @@ import (
11 "strings"
12
13 "github.com/gorilla/websocket"
14 - "github.com/rs/zerolog/log"
14
16 - "gosuda.org/portal/portal/core/cryptoops"
15 "gosuda.org/portal/portal/utils/wsstream"
16 )
17
20 -func NewCredential() *cryptoops.Credential {
21 - cred, err := cryptoops.NewCredential()
22 - if err != nil {
23 - log.Fatal().Err(err).Msg("Failed to create credential")
24 - }
25 - return cred
26 -}
27 -
18 // NewWebSocketDialer returns a dialer that establishes WebSocket connections
19 // and wraps them as io.ReadWriteCloser.
20 func NewWebSocketDialer() func(context.Context, string) (io.ReadWriteCloser, error) {
@@ -59,10 +49,10 @@ func UpgradeToWSStream(w http.ResponseWriter, r *http.Request, responseHeader ht
49 // URL-safe name validation regex
50 var urlSafeNameRegex = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
51
62 -// isURLSafeName checks if a name contains only URL-safe characters.
52 +// IsURLSafeName checks if a name contains only URL-safe characters.
53 // Disallows: spaces, special characters like /, ?, &, =, %, etc.
54 // Note: Browsers will automatically URL-encode non-ASCII characters.
65 -func isURLSafeName(name string) bool {
55 +func IsURLSafeName(name string) bool {
56 if name == "" {
57 return true // Empty name is allowed (will be treated as unnamed)
58 }
utils/utils_test.go renamed
+2 -2
@@ -1,4 +1,4 @@
1 -package sdk
1 +package utils
2
3 import (
4 "testing"
@@ -67,7 +67,7 @@ func TestIsURLSafeName(t *testing.T) {
67
68 for _, tt := range tests {
69 t.Run(tt.name, func(t *testing.T) {
70 - result := isURLSafeName(tt.input)
70 + result := IsURLSafeName(tt.input)
71 assert.Equal(t, tt.expected, result, "isURLSafeName(%q)", tt.input)
72 })
73 }