├── .gitignore ├── .ls-lint.yml ├── LICENSE ├── Makefile ├── README.md ├── examples └── basic.go ├── go.mod ├── go.sum ├── keepalive.go ├── recws-logo.png └── recws.go /.gitignore: -------------------------------------------------------------------------------- 1 | .history 2 | /.idea 3 | ls-lint -------------------------------------------------------------------------------- /.ls-lint.yml: -------------------------------------------------------------------------------- 1 | ls: 2 | dir: snake_case 3 | .*: snake_case 4 | .md: SCREAMING_SNAKE_CASE 5 | .png: kebab-case 6 | 7 | ignore: 8 | - .git 9 | - .idea 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Marius Dobre 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | go get 3 | 4 | linter-golangci-lint: 5 | golangci-lint run 6 | 7 | linter-ls-lint: 8 | curl -sL -o ls-lint https://github.com/loeffel-io/ls-lint/releases/download/v2.3.0-beta.3/ls-lint-darwin-arm64 && chmod +x ls-lint && ./ls-lint 9 | 10 | linter: 11 | make linter-ls-lint 12 | make linter-golangci-lint 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | logo 2 | 3 | # recws 4 | 5 | Reconnecting WebSocket is a websocket client based on [gorilla/websocket](https://github.com/gorilla/websocket) that will automatically reconnect if the connection is dropped - thread safe! 6 | 7 | [![GoDoc](https://godoc.org/github.com/mariuspass/recws?status.svg)](https://godoc.org/github.com/mariuspass/recws) 8 | [![Go Report Card](https://goreportcard.com/badge/github.com/mariuspass/recws)](https://goreportcard.com/report/github.com/mariuspass/recws) 9 | [![GitHub license](https://img.shields.io/github/license/Naereen/StrapDown.js.svg)](https://github.com/Naereen/StrapDown.js/blob/master/LICENSE) 10 | 11 | ## Installation 12 | 13 | ```bash 14 | go get github.com/recws-org/recws 15 | ``` 16 | 17 | ## Logo 18 | 19 | - Logo by [Anastasia Marx](https://www.behance.net/AnastasiaMarx) 20 | - Gopher by [Gophers](https://github.com/egonelbre/gophers) 21 | 22 | ## License 23 | 24 | recws is open-source software licensed under the [MIT license](https://opensource.org/licenses/MIT). 25 | 26 | -------------------------------------------------------------------------------- /examples/basic.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "time" 7 | 8 | "github.com/recws-org/recws" 9 | ) 10 | 11 | func main() { 12 | ctx, cancel := context.WithCancel(context.Background()) 13 | ws := recws.RecConn{ 14 | KeepAliveTimeout: 10 * time.Second, 15 | } 16 | ws.Dial("wss://echo.websocket.org", nil) 17 | 18 | go func() { 19 | time.Sleep(2 * time.Second) 20 | cancel() 21 | }() 22 | 23 | for { 24 | select { 25 | case <-ctx.Done(): 26 | go ws.Close() 27 | log.Printf("Websocket closed %s", ws.GetURL()) 28 | return 29 | default: 30 | if !ws.IsConnected() { 31 | log.Printf("Websocket disconnected %s", ws.GetURL()) 32 | continue 33 | } 34 | 35 | if err := ws.WriteMessage(1, []byte("Incoming")); err != nil { 36 | log.Printf("Error: WriteMessage %s", ws.GetURL()) 37 | return 38 | } 39 | 40 | _, message, err := ws.ReadMessage() 41 | if err != nil { 42 | log.Printf("Error: ReadMessage %s", ws.GetURL()) 43 | return 44 | } 45 | 46 | log.Printf("Success: %s", message) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/recws-org/recws 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/gorilla/websocket v1.5.3 7 | github.com/jpillora/backoff v1.0.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 2 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 3 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 4 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 5 | -------------------------------------------------------------------------------- /keepalive.go: -------------------------------------------------------------------------------- 1 | package recws 2 | 3 | import ( 4 | "sync" 5 | "time" 6 | ) 7 | 8 | type keepAliveResponse struct { 9 | lastResponse time.Time 10 | sync.RWMutex 11 | } 12 | 13 | func (k *keepAliveResponse) setLastResponse() { 14 | k.Lock() 15 | defer k.Unlock() 16 | 17 | k.lastResponse = time.Now() 18 | } 19 | 20 | func (k *keepAliveResponse) getLastResponse() time.Time { 21 | k.RLock() 22 | defer k.RUnlock() 23 | 24 | return k.lastResponse 25 | } 26 | -------------------------------------------------------------------------------- /recws-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/recws-org/recws/19e327357a28ad6b53b7e515687c5c94e1cf7752/recws-logo.png -------------------------------------------------------------------------------- /recws.go: -------------------------------------------------------------------------------- 1 | // Package recws provides websocket client based on gorilla/websocket 2 | // that will automatically reconnect if the connection is dropped. 3 | package recws 4 | 5 | import ( 6 | "crypto/tls" 7 | "errors" 8 | "log" 9 | "math/rand" 10 | "net/http" 11 | "net/url" 12 | "sync" 13 | "time" 14 | 15 | "github.com/gorilla/websocket" 16 | "github.com/jpillora/backoff" 17 | ) 18 | 19 | // ErrNotConnected is returned when the application read/writes 20 | // a message and the connection is closed 21 | var ErrNotConnected = errors.New("websocket: not connected") 22 | 23 | // The RecConn type represents a Reconnecting WebSocket connection. 24 | type RecConn struct { 25 | // RecIntvlMin specifies the initial reconnecting interval, 26 | // default to 2 seconds 27 | RecIntvlMin time.Duration 28 | // RecIntvlMax specifies the maximum reconnecting interval, 29 | // default to 30 seconds 30 | RecIntvlMax time.Duration 31 | // RecIntvlFactor specifies the rate of increase of the reconnection 32 | // interval, default to 1.5 33 | RecIntvlFactor float64 34 | // HandshakeTimeout specifies the duration for the handshake to complete, 35 | // default to 2 seconds 36 | HandshakeTimeout time.Duration 37 | // Proxy specifies the proxy function for the dialer 38 | // defaults to ProxyFromEnvironment 39 | Proxy func(*http.Request) (*url.URL, error) 40 | // Client TLS config to use on reconnect 41 | TLSClientConfig *tls.Config 42 | // SubscribeHandler fires after the connection successfully establish. 43 | SubscribeHandler func() error 44 | // KeepAliveTimeout is an interval for sending ping/pong messages 45 | // disabled if 0 46 | KeepAliveTimeout time.Duration 47 | // NonVerbose suppress connecting/reconnecting messages. 48 | NonVerbose bool 49 | // Compression enables per-message compression as defined in https://datatracker.ietf.org/doc/html/rfc7692 50 | Compression bool 51 | 52 | isConnected bool 53 | mu sync.RWMutex 54 | url string 55 | reqHeader http.Header 56 | httpResp *http.Response 57 | dialErr error 58 | dialer *websocket.Dialer 59 | 60 | *websocket.Conn 61 | } 62 | 63 | // CloseAndReconnect will try to reconnect. 64 | func (rc *RecConn) CloseAndReconnect() { 65 | rc.Close() 66 | go rc.connect() 67 | } 68 | 69 | // setIsConnected sets state for isConnected 70 | func (rc *RecConn) setIsConnected(state bool) { 71 | rc.mu.Lock() 72 | defer rc.mu.Unlock() 73 | 74 | rc.isConnected = state 75 | } 76 | 77 | func (rc *RecConn) getConn() *websocket.Conn { 78 | rc.mu.RLock() 79 | defer rc.mu.RUnlock() 80 | 81 | return rc.Conn 82 | } 83 | 84 | // Close closes the underlying network connection without 85 | // sending or waiting for a close frame. 86 | func (rc *RecConn) Close() { 87 | if rc.getConn() != nil { 88 | rc.mu.Lock() 89 | rc.Conn.Close() 90 | rc.mu.Unlock() 91 | } 92 | 93 | rc.setIsConnected(false) 94 | } 95 | 96 | // Shutdown gracefully closes the connection by sending the websocket.CloseMessage. 97 | // The writeWait param defines the duration before the deadline of the write operation is hit. 98 | func (rc *RecConn) Shutdown(writeWait time.Duration) { 99 | msg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") 100 | err := rc.WriteControl(websocket.CloseMessage, msg, time.Now().Add(writeWait)) 101 | if err != nil && err != websocket.ErrCloseSent { 102 | // If close message could not be sent, then close without the handshake. 103 | log.Printf("Shutdown: %v", err) 104 | rc.Close() 105 | } 106 | } 107 | 108 | // ReadMessage is a helper method for getting a reader 109 | // using NextReader and reading from that reader to a buffer. 110 | // 111 | // If the connection is closed ErrNotConnected is returned 112 | func (rc *RecConn) ReadMessage() (messageType int, message []byte, err error) { 113 | err = ErrNotConnected 114 | if rc.IsConnected() { 115 | messageType, message, err = rc.Conn.ReadMessage() 116 | if websocket.IsCloseError(err, websocket.CloseNormalClosure) { 117 | rc.Close() 118 | return messageType, message, nil 119 | } 120 | if err != nil { 121 | rc.CloseAndReconnect() 122 | } 123 | } 124 | 125 | return 126 | } 127 | 128 | // WriteMessage is a helper method for getting a writer using NextWriter, 129 | // writing the message and closing the writer. 130 | // 131 | // If the connection is closed ErrNotConnected is returned 132 | func (rc *RecConn) WriteMessage(messageType int, data []byte) error { 133 | err := ErrNotConnected 134 | if rc.IsConnected() { 135 | rc.mu.Lock() 136 | err = rc.Conn.WriteMessage(messageType, data) 137 | rc.mu.Unlock() 138 | if websocket.IsCloseError(err, websocket.CloseNormalClosure) { 139 | rc.Close() 140 | return nil 141 | } 142 | if err != nil { 143 | rc.CloseAndReconnect() 144 | } 145 | } 146 | 147 | return err 148 | } 149 | 150 | // WriteJSON writes the JSON encoding of v to the connection. 151 | // 152 | // See the documentation for encoding/json Marshal for details about the 153 | // conversion of Go values to JSON. 154 | // 155 | // If the connection is closed ErrNotConnected is returned 156 | func (rc *RecConn) WriteJSON(v interface{}) error { 157 | err := ErrNotConnected 158 | if rc.IsConnected() { 159 | rc.mu.Lock() 160 | err = rc.Conn.WriteJSON(v) 161 | rc.mu.Unlock() 162 | if websocket.IsCloseError(err, websocket.CloseNormalClosure) { 163 | rc.Close() 164 | return nil 165 | } 166 | if err != nil { 167 | rc.CloseAndReconnect() 168 | } 169 | } 170 | 171 | return err 172 | } 173 | 174 | // ReadJSON reads the next JSON-encoded message from the connection and stores 175 | // it in the value pointed to by v. 176 | // 177 | // See the documentation for the encoding/json Unmarshal function for details 178 | // about the conversion of JSON to a Go value. 179 | // 180 | // If the connection is closed ErrNotConnected is returned 181 | func (rc *RecConn) ReadJSON(v interface{}) error { 182 | err := ErrNotConnected 183 | if rc.IsConnected() { 184 | err = rc.Conn.ReadJSON(v) 185 | if websocket.IsCloseError(err, websocket.CloseNormalClosure) { 186 | rc.Close() 187 | return nil 188 | } 189 | if err != nil { 190 | rc.CloseAndReconnect() 191 | } 192 | } 193 | 194 | return err 195 | } 196 | 197 | func (rc *RecConn) setURL(url string) { 198 | rc.mu.Lock() 199 | defer rc.mu.Unlock() 200 | 201 | rc.url = url 202 | } 203 | 204 | func (rc *RecConn) setReqHeader(reqHeader http.Header) { 205 | rc.mu.Lock() 206 | defer rc.mu.Unlock() 207 | 208 | rc.reqHeader = reqHeader 209 | } 210 | 211 | // parseURL parses current url 212 | func (rc *RecConn) parseURL(urlStr string) (string, error) { 213 | if urlStr == "" { 214 | return "", errors.New("dial: url cannot be empty") 215 | } 216 | 217 | u, err := url.Parse(urlStr) 218 | if err != nil { 219 | return "", errors.New("url: " + err.Error()) 220 | } 221 | 222 | if u.Scheme != "ws" && u.Scheme != "wss" { 223 | return "", errors.New("url: websocket uris must start with ws or wss scheme") 224 | } 225 | 226 | if u.User != nil { 227 | return "", errors.New("url: user name and password are not allowed in websocket URIs") 228 | } 229 | 230 | return urlStr, nil 231 | } 232 | 233 | func (rc *RecConn) setDefaultRecIntvlMin() { 234 | rc.mu.Lock() 235 | defer rc.mu.Unlock() 236 | 237 | if rc.RecIntvlMin == 0 { 238 | rc.RecIntvlMin = 2 * time.Second 239 | } 240 | } 241 | 242 | func (rc *RecConn) setDefaultRecIntvlMax() { 243 | rc.mu.Lock() 244 | defer rc.mu.Unlock() 245 | 246 | if rc.RecIntvlMax == 0 { 247 | rc.RecIntvlMax = 30 * time.Second 248 | } 249 | } 250 | 251 | func (rc *RecConn) setDefaultRecIntvlFactor() { 252 | rc.mu.Lock() 253 | defer rc.mu.Unlock() 254 | 255 | if rc.RecIntvlFactor == 0 { 256 | rc.RecIntvlFactor = 1.5 257 | } 258 | } 259 | 260 | func (rc *RecConn) setDefaultHandshakeTimeout() { 261 | rc.mu.Lock() 262 | defer rc.mu.Unlock() 263 | 264 | if rc.HandshakeTimeout == 0 { 265 | rc.HandshakeTimeout = 2 * time.Second 266 | } 267 | } 268 | 269 | func (rc *RecConn) setDefaultProxy() { 270 | rc.mu.Lock() 271 | defer rc.mu.Unlock() 272 | 273 | if rc.Proxy == nil { 274 | rc.Proxy = http.ProxyFromEnvironment 275 | } 276 | } 277 | 278 | func (rc *RecConn) setDefaultDialer(tlsClientConfig *tls.Config, handshakeTimeout time.Duration, compression bool) { 279 | rc.mu.Lock() 280 | defer rc.mu.Unlock() 281 | 282 | rc.dialer = &websocket.Dialer{ 283 | HandshakeTimeout: handshakeTimeout, 284 | Proxy: rc.Proxy, 285 | TLSClientConfig: tlsClientConfig, 286 | EnableCompression: compression, 287 | } 288 | } 289 | 290 | func (rc *RecConn) getHandshakeTimeout() time.Duration { 291 | rc.mu.RLock() 292 | defer rc.mu.RUnlock() 293 | 294 | return rc.HandshakeTimeout 295 | } 296 | 297 | func (rc *RecConn) getTLSClientConfig() *tls.Config { 298 | rc.mu.RLock() 299 | defer rc.mu.RUnlock() 300 | 301 | return rc.TLSClientConfig 302 | } 303 | 304 | func (rc *RecConn) SetTLSClientConfig(tlsClientConfig *tls.Config) { 305 | rc.mu.Lock() 306 | defer rc.mu.Unlock() 307 | 308 | rc.TLSClientConfig = tlsClientConfig 309 | } 310 | 311 | // Dial creates a new client connection. 312 | // The URL url specifies the host and request URI. Use requestHeader to specify 313 | // the origin (Origin), subprotocols (Sec-WebSocket-Protocol) and cookies 314 | // (Cookie). Use GetHTTPResponse() method for the response.Header to get 315 | // the selected subprotocol (Sec-WebSocket-Protocol) and cookies (Set-Cookie). 316 | func (rc *RecConn) Dial(urlStr string, reqHeader http.Header) { 317 | urlStr, err := rc.parseURL(urlStr) 318 | if err != nil { 319 | log.Fatalf("Dial: %v", err) 320 | } 321 | 322 | // Config 323 | rc.setURL(urlStr) 324 | rc.setReqHeader(reqHeader) 325 | rc.setDefaultRecIntvlMin() 326 | rc.setDefaultRecIntvlMax() 327 | rc.setDefaultRecIntvlFactor() 328 | rc.setDefaultHandshakeTimeout() 329 | rc.setDefaultProxy() 330 | rc.setDefaultDialer(rc.getTLSClientConfig(), rc.getHandshakeTimeout(), rc.Compression) 331 | 332 | // Connect 333 | go rc.connect() 334 | 335 | // wait on first attempt 336 | time.Sleep(rc.getHandshakeTimeout()) 337 | } 338 | 339 | // GetURL returns current connection url 340 | func (rc *RecConn) GetURL() string { 341 | rc.mu.RLock() 342 | defer rc.mu.RUnlock() 343 | 344 | return rc.url 345 | } 346 | 347 | func (rc *RecConn) getNonVerbose() bool { 348 | rc.mu.RLock() 349 | defer rc.mu.RUnlock() 350 | 351 | return rc.NonVerbose 352 | } 353 | 354 | func (rc *RecConn) getBackoff() *backoff.Backoff { 355 | rc.mu.RLock() 356 | defer rc.mu.RUnlock() 357 | 358 | return &backoff.Backoff{ 359 | Min: rc.RecIntvlMin, 360 | Max: rc.RecIntvlMax, 361 | Factor: rc.RecIntvlFactor, 362 | Jitter: true, 363 | } 364 | } 365 | 366 | func (rc *RecConn) hasSubscribeHandler() bool { 367 | rc.mu.RLock() 368 | defer rc.mu.RUnlock() 369 | 370 | return rc.SubscribeHandler != nil 371 | } 372 | 373 | func (rc *RecConn) getKeepAliveTimeout() time.Duration { 374 | rc.mu.RLock() 375 | defer rc.mu.RUnlock() 376 | 377 | return rc.KeepAliveTimeout 378 | } 379 | 380 | func (rc *RecConn) writeControlPingMessage() error { 381 | rc.mu.Lock() 382 | defer rc.mu.Unlock() 383 | 384 | return rc.Conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(10*time.Second)) 385 | } 386 | 387 | func (rc *RecConn) keepAlive() { 388 | var ( 389 | keepAliveResponse = new(keepAliveResponse) 390 | ticker = time.NewTicker(rc.getKeepAliveTimeout()) 391 | ) 392 | 393 | rc.mu.Lock() 394 | rc.Conn.SetPongHandler(func(msg string) error { 395 | keepAliveResponse.setLastResponse() 396 | return nil 397 | }) 398 | rc.mu.Unlock() 399 | 400 | go func() { 401 | defer ticker.Stop() 402 | 403 | for { 404 | if !rc.IsConnected() { 405 | continue 406 | } 407 | 408 | if err := rc.writeControlPingMessage(); err != nil { 409 | log.Println(err) 410 | } 411 | 412 | <-ticker.C 413 | if time.Since(keepAliveResponse.getLastResponse()) > rc.getKeepAliveTimeout() { 414 | rc.CloseAndReconnect() 415 | return 416 | } 417 | } 418 | }() 419 | } 420 | 421 | func (rc *RecConn) connect() { 422 | b := rc.getBackoff() 423 | rand.Seed(time.Now().UTC().UnixNano()) 424 | 425 | for { 426 | nextItvl := b.Duration() 427 | wsConn, httpResp, err := rc.dialer.Dial(rc.url, rc.reqHeader) 428 | 429 | rc.mu.Lock() 430 | rc.Conn = wsConn 431 | rc.dialErr = err 432 | rc.isConnected = err == nil 433 | rc.httpResp = httpResp 434 | rc.mu.Unlock() 435 | 436 | if err == nil { 437 | if !rc.getNonVerbose() { 438 | log.Printf("Dial: connection was successfully established with %s\n", rc.url) 439 | } 440 | 441 | if rc.hasSubscribeHandler() { 442 | if err := rc.SubscribeHandler(); err != nil { 443 | log.Fatalf("Dial: connect handler failed with %s", err.Error()) 444 | } 445 | if !rc.getNonVerbose() { 446 | log.Printf("Dial: connect handler was successfully established with %s\n", rc.url) 447 | } 448 | } 449 | 450 | if rc.getKeepAliveTimeout() != 0 { 451 | rc.keepAlive() 452 | } 453 | 454 | return 455 | } 456 | 457 | if !rc.getNonVerbose() { 458 | log.Println(err) 459 | log.Println("Dial: will try again in", nextItvl, "seconds.") 460 | } 461 | 462 | time.Sleep(nextItvl) 463 | } 464 | } 465 | 466 | // GetHTTPResponse returns the http response from the handshake. 467 | // Useful when WebSocket handshake fails, 468 | // so that callers can handle redirects, authentication, etc. 469 | func (rc *RecConn) GetHTTPResponse() *http.Response { 470 | rc.mu.RLock() 471 | defer rc.mu.RUnlock() 472 | 473 | return rc.httpResp 474 | } 475 | 476 | // GetDialError returns the last dialer error. 477 | // nil on successful connection. 478 | func (rc *RecConn) GetDialError() error { 479 | rc.mu.RLock() 480 | defer rc.mu.RUnlock() 481 | 482 | return rc.dialErr 483 | } 484 | 485 | // IsConnected returns the WebSocket connection state 486 | func (rc *RecConn) IsConnected() bool { 487 | rc.mu.RLock() 488 | defer rc.mu.RUnlock() 489 | 490 | return rc.isConnected 491 | } 492 | --------------------------------------------------------------------------------