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
}