feat(webclient): replace wsjs with gorilla/websocket for enhanced protocol handling
Refactor WebSocket implementation in webclient to use gorilla/websocket library instead of custom wsjs, enabling support for subprotocols, message types (text/binary), and negotiated protocols. This improves compatibility and allows integration with the existing rdClient for dialing via rdDialer. Changes include updating connection creation, message sending/receiving, and struct definitions to leverage gorilla/websocket's features.
lemon-mint committed
Nov 1, 2025 at 02:02 UTC
31a98f3a420ecb23c34f7d480941012af9a89470
2 files changed
+115
-36
cmd/webclient/main_js.go
+87
-35
@@ -18,8 +18,8 @@ import (
18
"syscall/js"
19
"time"
20
21
+ "github.com/gorilla/websocket"
22
"github.com/gosuda/portal/cmd/webclient/httpjs"
22
- "github.com/gosuda/portal/cmd/webclient/wsjs"
23
"github.com/gosuda/portal/sdk"
24
"github.com/rs/zerolog"
25
"github.com/rs/zerolog/log"
@@ -31,21 +31,23 @@ var (
31
rdClient *sdk.RDClient
32
)
33
34
+var rdDialer = func(ctx context.Context, network, address string) (net.Conn, error) {
35
+ address = strings.TrimSuffix(address, ":80")
36
+ address = strings.TrimSuffix(address, ":443")
37
+ cred := sdk.NewCredential()
38
+ conn, err := rdClient.Dial(cred, address, "http/1.1")
39
+ if err != nil {
40
+ return nil, err
41
+ }
42
+ return conn, nil
43
+}
44
+
45
var client = &http.Client{
46
Timeout: time.Second * 30,
47
Transport: &http.Transport{
48
MaxIdleConns: 1000,
49
MaxIdleConnsPerHost: 100,
39
- DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
40
- address = strings.TrimSuffix(address, ":80")
41
- address = strings.TrimSuffix(address, ":443")
42
- cred := sdk.NewCredential()
43
- conn, err := rdClient.Dial(cred, address, "http/1.1")
44
- if err != nil {
45
- return nil, err
46
- }
47
- return conn, nil
48
- },
50
+ DialContext: rdDialer,
51
},
52
}
53
@@ -60,13 +62,18 @@ type WebSocketManager struct {
62
63
type WSConnection struct {
64
id string
63
- conn *wsjs.Conn
64
- messageChan chan []byte
65
+ conn *websocket.Conn
66
+ messageChan chan wsMessage
67
closeChan chan struct{}
68
closeOnce sync.Once
69
mu sync.Mutex
70
}
71
72
+type wsMessage struct {
73
+ data []byte
74
+ isText bool
75
+}
76
+
77
type ConnectRequest struct {
78
URL string `json:"url"`
79
Protocols []string `json:"protocols"`
@@ -85,10 +92,11 @@ type SendRequest struct {
92
}
93
94
type StreamMessage struct {
88
- Type string `json:"type"` // "message", "close"
89
- Data string `json:"data,omitempty"`
90
- Code int `json:"code,omitempty"`
91
- Reason string `json:"reason,omitempty"`
95
+ Type string `json:"type"` // "message", "close"
96
+ Data string `json:"data,omitempty"`
97
+ MessageType string `json:"messageType,omitempty"` // "text", "binary"
98
+ Code int `json:"code,omitempty"`
99
+ Reason string `json:"reason,omitempty"`
100
}
101
102
func NewWebSocketManager() *WebSocketManager {
@@ -101,16 +109,28 @@ func generateConnID() string {
109
return hex.EncodeToString(b)
110
}
111
104
-func (m *WebSocketManager) CreateConnection(url string) (*WSConnection, error) {
105
- conn, err := wsjs.Dial(url)
112
+func (m *WebSocketManager) CreateConnection(url string, protocols []string) (*WSConnection, string, error) {
113
+ // Parse URL to extract host for rdDialer
114
+ dialer := websocket.Dialer{
115
+ NetDialContext: rdDialer,
116
+ Subprotocols: protocols,
117
+ }
118
+
119
+ conn, resp, err := dialer.Dial(url, nil)
120
if err != nil {
107
- return nil, err
121
+ return nil, "", err
122
+ }
123
+
124
+ // Get negotiated protocol
125
+ negotiatedProtocol := ""
126
+ if resp != nil && resp.Header != nil {
127
+ negotiatedProtocol = resp.Header.Get("Sec-WebSocket-Protocol")
128
}
129
130
wsConn := &WSConnection{
131
id: generateConnID(),
132
conn: conn,
113
- messageChan: make(chan []byte, 100),
133
+ messageChan: make(chan wsMessage, 100),
134
closeChan: make(chan struct{}),
135
}
136
@@ -119,7 +139,7 @@ func (m *WebSocketManager) CreateConnection(url string) (*WSConnection, error) {
139
// Start message receiver
140
go wsConn.receiveMessages()
141
122
- return wsConn, nil
142
+ return wsConn, negotiatedProtocol, nil
143
}
144
145
func (m *WebSocketManager) GetConnection(id string) (*WSConnection, bool) {
@@ -138,21 +158,31 @@ func (c *WSConnection) receiveMessages() {
158
defer c.Close()
159
160
for {
141
- msg, err := c.conn.NextMessage()
161
+ messageType, msg, err := c.conn.ReadMessage()
162
if err != nil {
163
log.Error().Err(err).Str("connId", c.id).Msg("Error receiving message")
164
return
165
}
166
167
+ // Only handle binary and text messages
168
+ if messageType != websocket.BinaryMessage && messageType != websocket.TextMessage {
169
+ continue
170
+ }
171
+
172
+ wsMsg := wsMessage{
173
+ data: msg,
174
+ isText: messageType == websocket.TextMessage,
175
+ }
176
+
177
select {
148
- case c.messageChan <- msg:
178
+ case c.messageChan <- wsMsg:
179
case <-c.closeChan:
180
return
181
}
182
}
183
}
184
155
-func (c *WSConnection) Send(data []byte) error {
185
+func (c *WSConnection) Send(data []byte, isText bool) error {
186
c.mu.Lock()
187
defer c.mu.Unlock()
188
@@ -160,13 +190,18 @@ func (c *WSConnection) Send(data []byte) error {
190
case <-c.closeChan:
191
return fmt.Errorf("connection closed")
192
default:
163
- return c.conn.Send(data)
193
+ messageType := websocket.BinaryMessage
194
+ if isText {
195
+ messageType = websocket.TextMessage
196
+ }
197
+ return c.conn.WriteMessage(messageType, data)
198
}
199
}
200
201
func (c *WSConnection) Close() {
202
c.closeOnce.Do(func() {
203
close(c.closeChan)
204
+ c.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
205
c.conn.Close()
206
})
207
}
@@ -268,9 +303,9 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
303
return
304
}
305
271
- log.Info().Str("url", req.URL).Msg("Creating WebSocket connection")
306
+ log.Info().Str("url", req.URL).Strs("protocols", req.Protocols).Msg("Creating WebSocket connection")
307
273
- wsConn, err := p.wsManager.CreateConnection(req.URL)
308
+ wsConn, protocol, err := p.wsManager.CreateConnection(req.URL, req.Protocols)
309
if err != nil {
310
log.Error().Err(err).Msg("Failed to create WebSocket connection")
311
http.Error(w, fmt.Sprintf("Failed to connect: %v", err), http.StatusBadGateway)
@@ -279,7 +314,7 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
314
315
resp := ConnectResponse{
316
ConnID: wsConn.id,
282
- Protocol: "", // TODO: handle protocol negotiation
317
+ Protocol: protocol,
318
}
319
320
w.Header().Set("Content-Type", "application/json")
@@ -295,6 +330,12 @@ func (p *Proxy) handleStream(w http.ResponseWriter, r *http.Request, connID stri
330
331
log.Info().Str("connId", connID).Msg("Starting message stream")
332
333
+ // Cleanup on exit
334
+ defer func() {
335
+ p.wsManager.RemoveConnection(connID)
336
+ wsConn.Close()
337
+ }()
338
+
339
// Set headers for streaming
340
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
341
w.Header().Set("Cache-Control", "no-cache")
@@ -312,9 +353,16 @@ func (p *Proxy) handleStream(w http.ResponseWriter, r *http.Request, connID stri
353
for {
354
select {
355
case msg := <-wsConn.messageChan:
356
+ // Use message type from WebSocket frame
357
+ messageType := "binary"
358
+ if msg.isText {
359
+ messageType = "text"
360
+ }
361
+
362
streamMsg := StreamMessage{
316
- Type: "message",
317
- Data: base64.StdEncoding.EncodeToString(msg),
363
+ Type: "message",
364
+ Data: base64.StdEncoding.EncodeToString(msg.data),
365
+ MessageType: messageType,
366
}
367
if err := encoder.Encode(streamMsg); err != nil {
368
log.Error().Err(err).Msg("Failed to encode message")
@@ -362,21 +410,25 @@ func (p *Proxy) handleSend(w http.ResponseWriter, r *http.Request, connID string
410
411
var data []byte
412
var err error
413
+ var isText bool
414
366
- if req.Type == "binary" {
415
+ switch req.Type {
416
+ case "binary":
417
data, err = base64.StdEncoding.DecodeString(req.Data)
418
if err != nil {
419
http.Error(w, "Invalid base64 data", http.StatusBadRequest)
420
return
421
}
372
- } else if req.Type == "text" {
422
+ isText = false
423
+ case "text":
424
data = []byte(req.Data)
374
- } else {
425
+ isText = true
426
+ default:
427
http.Error(w, "Invalid message type", http.StatusBadRequest)
428
return
429
}
430
379
- if err := wsConn.Send(data); err != nil {
431
+ if err := wsConn.Send(data, isText); err != nil {
432
log.Error().Err(err).Msg("Failed to send message")
433
http.Error(w, fmt.Sprintf("Failed to send: %v", err), http.StatusInternalServerError)
434
return
cmd/webclient/polyfill.js
+28
-1
@@ -134,9 +134,36 @@
134
return;
135
}
136
137
+ // Decode data from base64
138
+ let data;
139
+ try {
140
+ const binaryString = atob(message.data);
141
+ const bytes = new Uint8Array(binaryString.length);
142
+ for (let i = 0; i < binaryString.length; i++) {
143
+ bytes[i] = binaryString.charCodeAt(i);
144
+ }
145
+
146
+ // Use messageType from server to determine if text or binary
147
+ if (message.messageType === 'text') {
148
+ // Decode as text
149
+ const decoder = new TextDecoder('utf-8');
150
+ data = decoder.decode(bytes);
151
+ } else {
152
+ // Binary message - respect binaryType setting
153
+ if (this.binaryType === 'blob') {
154
+ data = new Blob([bytes]);
155
+ } else {
156
+ data = bytes.buffer;
157
+ }
158
+ }
159
+ } catch (e) {
160
+ console.error('Failed to decode message:', e);
161
+ return;
162
+ }
163
+
164
// Create MessageEvent
165
const event = new MessageEvent('message', {
139
- data: message.data,
166
+ data: data,
167
origin: new URL(this.url).origin
168
});
169