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