├── .github └── workflows │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config.go ├── doc.go ├── envelope.go ├── errors.go ├── examples ├── chat-echo │ ├── index.html │ └── main.go ├── chat-gin │ ├── index.html │ └── main.go ├── chat │ ├── demo.gif │ ├── index.html │ └── main.go ├── filewatch │ ├── file.txt │ ├── index.html │ └── main.go ├── go.mod ├── go.sum ├── gophers │ ├── demo.gif │ ├── index.html │ └── main.go └── multichat │ ├── chan.html │ ├── index.html │ └── main.go ├── go.mod ├── go.sum ├── hub.go ├── melody.go ├── melody_test.go └── session.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | test: 8 | strategy: 9 | matrix: 10 | go-version: ["1.20", "1.21", "1.22"] 11 | runs-on: "ubuntu-latest" 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 2 16 | - uses: actions/setup-go@v3 17 | with: 18 | go-version: ${{ matrix.go-version }} 19 | - run: go get -t -v ./... 20 | - run: go test -race -coverprofile=coverage.out -covermode=atomic -timeout 60s 21 | - uses: codecov/codecov-action@v3 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | benchmark 3 | *.swp 4 | coverage.out 5 | Makefile 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2022-09-12 (v1.1.0) 2 | 3 | * Create Go module. 4 | * Update examples. 5 | * Fix concurrent panic (PR-65). 6 | * Add `Sessions` to get all sessions (PR-53). 7 | * Add `LocalAddr` and `RemoteAddr` (PR-55). 8 | 9 | ## 2017-05-18 10 | 11 | * Fix `HandleSentMessageBinary`. 12 | 13 | ## 2017-04-11 14 | 15 | * Allow any origin by default. 16 | * Add `BroadcastMultiple`. 17 | 18 | ## 2017-04-09 19 | 20 | * Add control message support. 21 | * Add `IsClosed` to Session. 22 | 23 | ## 2017-02-10 24 | 25 | * Return errors for some exposed methods. 26 | * Add `HandleRequestWithKeys`. 27 | * Add `HandleSentMessage` and `HandleSentMessageBinary`. 28 | 29 | ## 2017-01-20 30 | 31 | * Add `Len()` to fetch number of connected sessions. 32 | 33 | ## 2016-12-09 34 | 35 | * Add metadata management for sessions. 36 | 37 | ## 2016-05-09 38 | 39 | * Add method `HandlePong` to melody instance. 40 | 41 | ## 2015-10-07 42 | 43 | * Add broadcast methods for binary messages. 44 | 45 | ## 2015-09-03 46 | 47 | * Add `Close` method to melody instance. 48 | 49 | ### 2015-06-10 50 | 51 | * Support for binary messages. 52 | * BroadcastOthers method. 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Ola Holmström. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # melody 2 | 3 | ![Build Status](https://github.com/olahol/melody/actions/workflows/test.yml/badge.svg) 4 | [![Codecov](https://img.shields.io/codecov/c/github/olahol/melody)](https://app.codecov.io/github/olahol/melody) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/olahol/melody)](https://goreportcard.com/report/github.com/olahol/melody) 6 | [![GoDoc](https://godoc.org/github.com/olahol/melody?status.svg)](https://godoc.org/github.com/olahol/melody) 7 | 8 | > :notes: Minimalist websocket framework for Go. 9 | 10 | Melody is websocket framework based on [github.com/gorilla/websocket](https://github.com/gorilla/websocket) 11 | that abstracts away the tedious parts of handling websockets. It gets out of 12 | your way so you can write real-time apps. Features include: 13 | 14 | * [x] Clear and easy interface similar to `net/http` or Gin. 15 | * [x] A simple way to broadcast to all or selected connected sessions. 16 | * [x] Message buffers making concurrent writing safe. 17 | * [x] Automatic handling of sending ping/pong heartbeats that timeout broken sessions. 18 | * [x] Store data on sessions. 19 | 20 | ## Install 21 | 22 | ```bash 23 | go get github.com/olahol/melody 24 | ``` 25 | 26 | ## [Example: chat](https://github.com/olahol/melody/tree/master/examples/chat) 27 | 28 | [![Chat](https://cdn.rawgit.com/olahol/melody/master/examples/chat/demo.gif "Demo")](https://github.com/olahol/melody/tree/master/examples/chat) 29 | 30 | ```go 31 | package main 32 | 33 | import ( 34 | "net/http" 35 | 36 | "github.com/olahol/melody" 37 | ) 38 | 39 | func main() { 40 | m := melody.New() 41 | 42 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 43 | http.ServeFile(w, r, "index.html") 44 | }) 45 | 46 | http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { 47 | m.HandleRequest(w, r) 48 | }) 49 | 50 | m.HandleMessage(func(s *melody.Session, msg []byte) { 51 | m.Broadcast(msg) 52 | }) 53 | 54 | http.ListenAndServe(":5000", nil) 55 | } 56 | ``` 57 | 58 | ## [Example: gophers](https://github.com/olahol/melody/tree/master/examples/gophers) 59 | 60 | [![Gophers](https://cdn.rawgit.com/olahol/melody/master/examples/gophers/demo.gif "Demo")](https://github.com/olahol/melody/tree/master/examples/gophers) 61 | 62 | ```go 63 | package main 64 | 65 | import ( 66 | "fmt" 67 | "net/http" 68 | "sync/atomic" 69 | 70 | "github.com/olahol/melody" 71 | ) 72 | 73 | var idCounter atomic.Int64 74 | 75 | func main() { 76 | m := melody.New() 77 | 78 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 79 | http.ServeFile(w, r, "index.html") 80 | }) 81 | 82 | http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { 83 | m.HandleRequest(w, r) 84 | }) 85 | 86 | m.HandleConnect(func(s *melody.Session) { 87 | id := idCounter.Add(1) 88 | 89 | s.Set("id", id) 90 | 91 | s.Write([]byte(fmt.Sprintf("iam %d", id))) 92 | }) 93 | 94 | m.HandleDisconnect(func(s *melody.Session) { 95 | if id, ok := s.Get("id"); ok { 96 | m.BroadcastOthers([]byte(fmt.Sprintf("dis %d", id)), s) 97 | } 98 | }) 99 | 100 | m.HandleMessage(func(s *melody.Session, msg []byte) { 101 | if id, ok := s.Get("id"); ok { 102 | m.BroadcastOthers([]byte(fmt.Sprintf("set %d %s", id, msg)), s) 103 | } 104 | }) 105 | 106 | http.ListenAndServe(":5000", nil) 107 | } 108 | ``` 109 | 110 | ### [More examples](https://github.com/olahol/melody/tree/master/examples) 111 | 112 | ## [Documentation](https://godoc.org/github.com/olahol/melody) 113 | 114 | ## Contributors 115 | 116 | 117 | 118 | 119 | 120 | ## FAQ 121 | 122 | If you are getting a `403` when trying to connect to your websocket you can [change allow all origin hosts](http://godoc.org/github.com/gorilla/websocket#hdr-Origin_Considerations): 123 | 124 | ```go 125 | m := melody.New() 126 | m.Upgrader.CheckOrigin = func(r *http.Request) bool { return true } 127 | ``` 128 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package melody 2 | 3 | import "time" 4 | 5 | // Config melody configuration struct. 6 | type Config struct { 7 | WriteWait time.Duration // Duration until write times out. 8 | PongWait time.Duration // Timeout for waiting on pong. 9 | PingPeriod time.Duration // Duration between pings. 10 | MaxMessageSize int64 // Maximum size in bytes of a message. 11 | MessageBufferSize int // The max amount of messages that can be in a sessions buffer before it starts dropping them. 12 | ConcurrentMessageHandling bool // Handle messages from sessions concurrently. 13 | } 14 | 15 | func newConfig() *Config { 16 | return &Config{ 17 | WriteWait: 10 * time.Second, 18 | PongWait: 60 * time.Second, 19 | PingPeriod: 54 * time.Second, 20 | MaxMessageSize: 512, 21 | MessageBufferSize: 256, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2015 Ola Holmström. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | // Package melody implements a framework for dealing with WebSockets. 6 | // 7 | // Example 8 | // 9 | // A broadcasting echo server: 10 | // 11 | // func main() { 12 | // m := melody.New() 13 | // http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { 14 | // m.HandleRequest(w, r) 15 | // }) 16 | // m.HandleMessage(func(s *melody.Session, msg []byte) { 17 | // m.Broadcast(msg) 18 | // }) 19 | // http.ListenAndServe(":5000", nil) 20 | // } 21 | 22 | package melody 23 | -------------------------------------------------------------------------------- /envelope.go: -------------------------------------------------------------------------------- 1 | package melody 2 | 3 | type envelope struct { 4 | t int 5 | msg []byte 6 | filter filterFunc 7 | } 8 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package melody 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrClosed = errors.New("melody instance is closed") 7 | ErrSessionClosed = errors.New("session is closed") 8 | ErrWriteClosed = errors.New("tried to write to closed a session") 9 | ErrMessageBufferFull = errors.New("session message buffer is full") 10 | ) 11 | -------------------------------------------------------------------------------- /examples/chat-echo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Melody example: chatting 4 | 5 | 6 | 15 | 16 | 17 |
18 |

Chat

19 |

20 |       
21 |     
22 | 23 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /examples/chat-echo/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/labstack/echo/v4" 5 | "github.com/labstack/echo/v4/middleware" 6 | "github.com/olahol/melody" 7 | "net/http" 8 | ) 9 | 10 | func main() { 11 | e := echo.New() 12 | m := melody.New() 13 | 14 | e.Use(middleware.Logger()) 15 | e.Use(middleware.Recover()) 16 | 17 | e.GET("/", func(c echo.Context) error { 18 | http.ServeFile(c.Response().Writer, c.Request(), "index.html") 19 | return nil 20 | }) 21 | 22 | e.GET("/ws", func(c echo.Context) error { 23 | m.HandleRequest(c.Response().Writer, c.Request()) 24 | return nil 25 | }) 26 | 27 | m.HandleMessage(func(s *melody.Session, msg []byte) { 28 | m.Broadcast(msg) 29 | }) 30 | 31 | e.Logger.Fatal(e.Start(":5000")) 32 | } 33 | -------------------------------------------------------------------------------- /examples/chat-gin/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Melody example: chatting 4 | 5 | 6 | 15 | 16 | 17 |
18 |

Chat

19 |

20 |       
21 |     
22 | 23 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /examples/chat-gin/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gin-gonic/gin" 5 | "github.com/olahol/melody" 6 | "net/http" 7 | ) 8 | 9 | func main() { 10 | r := gin.Default() 11 | m := melody.New() 12 | 13 | r.GET("/", func(c *gin.Context) { 14 | http.ServeFile(c.Writer, c.Request, "index.html") 15 | }) 16 | 17 | r.GET("/ws", func(c *gin.Context) { 18 | m.HandleRequest(c.Writer, c.Request) 19 | }) 20 | 21 | m.HandleMessage(func(s *melody.Session, msg []byte) { 22 | m.Broadcast(msg) 23 | }) 24 | 25 | r.Run(":5000") 26 | } 27 | -------------------------------------------------------------------------------- /examples/chat/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olahol/melody/6bf86153260ddfd8ef1929323e0ee37cc3acd44a/examples/chat/demo.gif -------------------------------------------------------------------------------- /examples/chat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Melody example: chatting 4 | 5 | 6 | 15 | 16 | 17 |
18 |

Chat

19 |

20 |       
21 |     
22 | 23 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /examples/chat/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/olahol/melody" 7 | ) 8 | 9 | func main() { 10 | m := melody.New() 11 | 12 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 13 | http.ServeFile(w, r, "index.html") 14 | }) 15 | 16 | http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { 17 | m.HandleRequest(w, r) 18 | }) 19 | 20 | m.HandleMessage(func(s *melody.Session, msg []byte) { 21 | m.Broadcast(msg) 22 | }) 23 | 24 | http.ListenAndServe(":5000", nil) 25 | } 26 | -------------------------------------------------------------------------------- /examples/filewatch/file.txt: -------------------------------------------------------------------------------- 1 | Hello World! 2 | -------------------------------------------------------------------------------- /examples/filewatch/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Melody example: file watching 4 | 5 | 6 | 15 | 16 | 17 |
18 |

Watching a file

19 |

20 |     
21 | 22 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /examples/filewatch/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | "os" 6 | 7 | "github.com/fsnotify/fsnotify" 8 | "github.com/olahol/melody" 9 | ) 10 | 11 | func main() { 12 | file := "file.txt" 13 | 14 | m := melody.New() 15 | w, _ := fsnotify.NewWatcher() 16 | 17 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 18 | http.ServeFile(w, r, "index.html") 19 | }) 20 | 21 | http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { 22 | m.HandleRequest(w, r) 23 | }) 24 | 25 | m.HandleConnect(func(s *melody.Session) { 26 | content, _ := os.ReadFile(file) 27 | s.Write(content) 28 | }) 29 | 30 | go func() { 31 | for { 32 | ev := <-w.Events 33 | if ev.Op == fsnotify.Write { 34 | content, _ := os.ReadFile(ev.Name) 35 | m.Broadcast(content) 36 | } 37 | } 38 | }() 39 | 40 | w.Add(file) 41 | 42 | http.ListenAndServe(":5000", nil) 43 | } 44 | -------------------------------------------------------------------------------- /examples/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/olahol/melody/examples 2 | 3 | go 1.22 4 | 5 | replace github.com/olahol/melody => ../ 6 | 7 | require ( 8 | github.com/fsnotify/fsnotify v1.5.4 9 | github.com/gin-gonic/gin v1.9.1 10 | github.com/labstack/echo/v4 v4.9.0 11 | github.com/olahol/melody v0.0.0-00010101000000-000000000000 12 | ) 13 | 14 | require ( 15 | github.com/bytedance/sonic v1.9.1 // indirect 16 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 17 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 18 | github.com/gin-contrib/sse v0.1.0 // indirect 19 | github.com/go-playground/locales v0.14.1 // indirect 20 | github.com/go-playground/universal-translator v0.18.1 // indirect 21 | github.com/go-playground/validator/v10 v10.14.0 // indirect 22 | github.com/goccy/go-json v0.10.2 // indirect 23 | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect 24 | github.com/gorilla/websocket v1.5.0 // indirect 25 | github.com/json-iterator/go v1.1.12 // indirect 26 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect 27 | github.com/labstack/gommon v0.3.1 // indirect 28 | github.com/leodido/go-urn v1.2.4 // indirect 29 | github.com/mattn/go-colorable v0.1.11 // indirect 30 | github.com/mattn/go-isatty v0.0.19 // indirect 31 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 32 | github.com/modern-go/reflect2 v1.0.2 // indirect 33 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 34 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 35 | github.com/ugorji/go/codec v1.2.11 // indirect 36 | github.com/valyala/bytebufferpool v1.0.0 // indirect 37 | github.com/valyala/fasttemplate v1.2.1 // indirect 38 | golang.org/x/arch v0.3.0 // indirect 39 | golang.org/x/crypto v0.9.0 // indirect 40 | golang.org/x/net v0.10.0 // indirect 41 | golang.org/x/sys v0.8.0 // indirect 42 | golang.org/x/text v0.9.0 // indirect 43 | golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 // indirect 44 | google.golang.org/protobuf v1.30.0 // indirect 45 | gopkg.in/yaml.v3 v3.0.1 // indirect 46 | ) 47 | -------------------------------------------------------------------------------- /examples/go.sum: -------------------------------------------------------------------------------- 1 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= 2 | github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= 3 | github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= 4 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= 5 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= 6 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= 11 | github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= 12 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 13 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 14 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 15 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 16 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= 17 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= 18 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 19 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 20 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 21 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 22 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 23 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 24 | github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= 25 | github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 26 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 27 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 28 | github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 29 | github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 30 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 31 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 32 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 33 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 34 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 35 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 36 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 37 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 38 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 39 | github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= 40 | github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= 41 | github.com/labstack/echo/v4 v4.9.0 h1:wPOF1CE6gvt/kmbMR4dGzWvHMPT+sAEUJOwOTtvITVY= 42 | github.com/labstack/echo/v4 v4.9.0/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks= 43 | github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o= 44 | github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= 45 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 46 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 47 | github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs= 48 | github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 49 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 50 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 51 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 52 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 53 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 54 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 55 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 56 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 57 | github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= 58 | github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= 59 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 60 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 61 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 62 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 63 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 64 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 65 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 66 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 67 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 68 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 69 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 70 | github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 71 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 72 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 73 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 74 | github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= 75 | github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 76 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 77 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 78 | github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4= 79 | github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 80 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 81 | golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= 82 | golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 83 | golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g= 84 | golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= 85 | golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= 86 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 87 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 88 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 89 | golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 90 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 91 | golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 92 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 93 | golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= 94 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 95 | golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= 96 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 97 | golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE= 98 | golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= 99 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= 100 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 101 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 102 | google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= 103 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 104 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 105 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 106 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 107 | gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 108 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 109 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 110 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 111 | -------------------------------------------------------------------------------- /examples/gophers/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/olahol/melody/6bf86153260ddfd8ef1929323e0ee37cc3acd44a/examples/gophers/demo.gif -------------------------------------------------------------------------------- /examples/gophers/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | goofy gophers 5 | 20 | 21 | 22 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /examples/gophers/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "sync/atomic" 7 | 8 | "github.com/olahol/melody" 9 | ) 10 | 11 | var idCounter atomic.Int64 12 | 13 | func main() { 14 | m := melody.New() 15 | 16 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 17 | http.ServeFile(w, r, "index.html") 18 | }) 19 | 20 | http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { 21 | m.HandleRequest(w, r) 22 | }) 23 | 24 | m.HandleConnect(func(s *melody.Session) { 25 | id := idCounter.Add(1) 26 | 27 | s.Set("id", id) 28 | 29 | s.Write([]byte(fmt.Sprintf("iam %d", id))) 30 | }) 31 | 32 | m.HandleDisconnect(func(s *melody.Session) { 33 | if id, ok := s.Get("id"); ok { 34 | m.BroadcastOthers([]byte(fmt.Sprintf("dis %d", id)), s) 35 | } 36 | }) 37 | 38 | m.HandleMessage(func(s *melody.Session, msg []byte) { 39 | if id, ok := s.Get("id"); ok { 40 | m.BroadcastOthers([]byte(fmt.Sprintf("set %d %s", id, msg)), s) 41 | } 42 | }) 43 | 44 | http.ListenAndServe(":5000", nil) 45 | } 46 | -------------------------------------------------------------------------------- /examples/multichat/chan.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Melody example: chatting 4 | 5 | 6 | 15 | 16 | 17 |
18 |

19 |

20 |       
21 |     
22 | 23 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /examples/multichat/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Melody example: chatting 4 | 5 | 6 | 15 | 16 | 17 |
18 |

Join a channel

19 | 20 |
21 | 22 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /examples/multichat/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/olahol/melody" 7 | ) 8 | 9 | func main() { 10 | m := melody.New() 11 | 12 | http.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) { 13 | http.ServeFile(w, r, "index.html") 14 | }) 15 | 16 | http.HandleFunc("GET /channel/{chan}", func(w http.ResponseWriter, r *http.Request) { 17 | http.ServeFile(w, r, "chan.html") 18 | }) 19 | 20 | http.HandleFunc("GET /channel/{chan}/ws", func(w http.ResponseWriter, r *http.Request) { 21 | m.HandleRequest(w, r) 22 | }) 23 | 24 | m.HandleMessage(func(s *melody.Session, msg []byte) { 25 | m.BroadcastFilter(msg, func(q *melody.Session) bool { 26 | return q.Request.URL.Path == s.Request.URL.Path 27 | }) 28 | }) 29 | 30 | http.ListenAndServe(":5000", nil) 31 | } 32 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/olahol/melody 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/gorilla/websocket v1.5.0 7 | github.com/stretchr/testify v1.8.0 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/pmezard/go-difflib v1.0.0 // indirect 13 | gopkg.in/yaml.v3 v3.0.1 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= 5 | github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 6 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 7 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 8 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 9 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 10 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 11 | github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= 12 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 15 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 16 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 17 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 18 | -------------------------------------------------------------------------------- /hub.go: -------------------------------------------------------------------------------- 1 | package melody 2 | 3 | import ( 4 | "sync" 5 | "sync/atomic" 6 | ) 7 | 8 | type sessionSet struct { 9 | mu sync.RWMutex 10 | members map[*Session]struct{} 11 | } 12 | 13 | func (ss *sessionSet) add(s *Session) { 14 | ss.mu.Lock() 15 | defer ss.mu.Unlock() 16 | 17 | ss.members[s] = struct{}{} 18 | } 19 | 20 | func (ss *sessionSet) del(s *Session) { 21 | ss.mu.Lock() 22 | defer ss.mu.Unlock() 23 | 24 | delete(ss.members, s) 25 | } 26 | 27 | func (ss *sessionSet) clear() { 28 | ss.mu.Lock() 29 | defer ss.mu.Unlock() 30 | 31 | ss.members = make(map[*Session]struct{}) 32 | } 33 | 34 | func (ss *sessionSet) each(cb func(*Session)) { 35 | ss.mu.RLock() 36 | defer ss.mu.RUnlock() 37 | 38 | for s := range ss.members { 39 | cb(s) 40 | } 41 | } 42 | 43 | func (ss *sessionSet) len() int { 44 | ss.mu.RLock() 45 | defer ss.mu.RUnlock() 46 | 47 | return len(ss.members) 48 | } 49 | 50 | func (ss *sessionSet) all() []*Session { 51 | ss.mu.RLock() 52 | defer ss.mu.RUnlock() 53 | 54 | s := make([]*Session, 0, len(ss.members)) 55 | for k := range ss.members { 56 | s = append(s, k) 57 | } 58 | 59 | return s 60 | } 61 | 62 | type hub struct { 63 | sessions sessionSet 64 | broadcast chan envelope 65 | register chan *Session 66 | unregister chan *Session 67 | exit chan envelope 68 | open atomic.Bool 69 | } 70 | 71 | func newHub() *hub { 72 | return &hub{ 73 | sessions: sessionSet{ 74 | members: make(map[*Session]struct{}), 75 | }, 76 | broadcast: make(chan envelope), 77 | register: make(chan *Session), 78 | unregister: make(chan *Session), 79 | exit: make(chan envelope), 80 | } 81 | } 82 | 83 | func (h *hub) run() { 84 | h.open.Store(true) 85 | 86 | loop: 87 | for { 88 | select { 89 | case s := <-h.register: 90 | h.sessions.add(s) 91 | case s := <-h.unregister: 92 | h.sessions.del(s) 93 | case m := <-h.broadcast: 94 | h.sessions.each(func(s *Session) { 95 | if m.filter == nil { 96 | s.writeMessage(m) 97 | } else if m.filter(s) { 98 | s.writeMessage(m) 99 | } 100 | }) 101 | case m := <-h.exit: 102 | h.open.Store(false) 103 | 104 | h.sessions.each(func(s *Session) { 105 | s.writeMessage(m) 106 | s.Close() 107 | }) 108 | 109 | h.sessions.clear() 110 | 111 | break loop 112 | } 113 | } 114 | } 115 | 116 | func (h *hub) closed() bool { 117 | return !h.open.Load() 118 | } 119 | 120 | func (h *hub) len() int { 121 | return h.sessions.len() 122 | } 123 | 124 | func (h *hub) all() []*Session { 125 | return h.sessions.all() 126 | } 127 | -------------------------------------------------------------------------------- /melody.go: -------------------------------------------------------------------------------- 1 | package melody 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | 7 | "github.com/gorilla/websocket" 8 | ) 9 | 10 | // Close codes defined in RFC 6455, section 11.7. 11 | // Duplicate of codes from gorilla/websocket for convenience. 12 | const ( 13 | CloseNormalClosure = 1000 14 | CloseGoingAway = 1001 15 | CloseProtocolError = 1002 16 | CloseUnsupportedData = 1003 17 | CloseNoStatusReceived = 1005 18 | CloseAbnormalClosure = 1006 19 | CloseInvalidFramePayloadData = 1007 20 | ClosePolicyViolation = 1008 21 | CloseMessageTooBig = 1009 22 | CloseMandatoryExtension = 1010 23 | CloseInternalServerErr = 1011 24 | CloseServiceRestart = 1012 25 | CloseTryAgainLater = 1013 26 | CloseTLSHandshake = 1015 27 | ) 28 | 29 | type handleMessageFunc func(*Session, []byte) 30 | type handleErrorFunc func(*Session, error) 31 | type handleCloseFunc func(*Session, int, string) error 32 | type handleSessionFunc func(*Session) 33 | type filterFunc func(*Session) bool 34 | 35 | // Melody implements a websocket manager. 36 | type Melody struct { 37 | Config *Config 38 | Upgrader *websocket.Upgrader 39 | messageHandler handleMessageFunc 40 | messageHandlerBinary handleMessageFunc 41 | messageSentHandler handleMessageFunc 42 | messageSentHandlerBinary handleMessageFunc 43 | errorHandler handleErrorFunc 44 | closeHandler handleCloseFunc 45 | connectHandler handleSessionFunc 46 | disconnectHandler handleSessionFunc 47 | pongHandler handleSessionFunc 48 | hub *hub 49 | } 50 | 51 | // New creates a new melody instance with default Upgrader and Config. 52 | func New() *Melody { 53 | upgrader := &websocket.Upgrader{ 54 | ReadBufferSize: 1024, 55 | WriteBufferSize: 1024, 56 | CheckOrigin: func(r *http.Request) bool { return true }, 57 | } 58 | 59 | hub := newHub() 60 | 61 | go hub.run() 62 | 63 | return &Melody{ 64 | Config: newConfig(), 65 | Upgrader: upgrader, 66 | messageHandler: func(*Session, []byte) {}, 67 | messageHandlerBinary: func(*Session, []byte) {}, 68 | messageSentHandler: func(*Session, []byte) {}, 69 | messageSentHandlerBinary: func(*Session, []byte) {}, 70 | errorHandler: func(*Session, error) {}, 71 | closeHandler: nil, 72 | connectHandler: func(*Session) {}, 73 | disconnectHandler: func(*Session) {}, 74 | pongHandler: func(*Session) {}, 75 | hub: hub, 76 | } 77 | } 78 | 79 | // HandleConnect fires fn when a session connects. 80 | func (m *Melody) HandleConnect(fn func(*Session)) { 81 | m.connectHandler = fn 82 | } 83 | 84 | // HandleDisconnect fires fn when a session disconnects. 85 | func (m *Melody) HandleDisconnect(fn func(*Session)) { 86 | m.disconnectHandler = fn 87 | } 88 | 89 | // HandlePong fires fn when a pong is received from a session. 90 | func (m *Melody) HandlePong(fn func(*Session)) { 91 | m.pongHandler = fn 92 | } 93 | 94 | // HandleMessage fires fn when a text message comes in. 95 | // NOTE: by default Melody handles messages sequentially for each 96 | // session. This has the effect that a message handler exceeding the 97 | // read deadline (Config.PongWait, by default 1 minute) will time out 98 | // the session. Concurrent message handling can be turned on by setting 99 | // Config.ConcurrentMessageHandling to true. 100 | func (m *Melody) HandleMessage(fn func(*Session, []byte)) { 101 | m.messageHandler = fn 102 | } 103 | 104 | // HandleMessageBinary fires fn when a binary message comes in. 105 | func (m *Melody) HandleMessageBinary(fn func(*Session, []byte)) { 106 | m.messageHandlerBinary = fn 107 | } 108 | 109 | // HandleSentMessage fires fn when a text message is successfully sent. 110 | func (m *Melody) HandleSentMessage(fn func(*Session, []byte)) { 111 | m.messageSentHandler = fn 112 | } 113 | 114 | // HandleSentMessageBinary fires fn when a binary message is successfully sent. 115 | func (m *Melody) HandleSentMessageBinary(fn func(*Session, []byte)) { 116 | m.messageSentHandlerBinary = fn 117 | } 118 | 119 | // HandleError fires fn when a session has an error. 120 | func (m *Melody) HandleError(fn func(*Session, error)) { 121 | m.errorHandler = fn 122 | } 123 | 124 | // HandleClose sets the handler for close messages received from the session. 125 | // The code argument to h is the received close code or CloseNoStatusReceived 126 | // if the close message is empty. The default close handler sends a close frame 127 | // back to the session. 128 | // 129 | // The application must read the connection to process close messages as 130 | // described in the section on Control Frames above. 131 | // 132 | // The connection read methods return a CloseError when a close frame is 133 | // received. Most applications should handle close messages as part of their 134 | // normal error handling. Applications should only set a close handler when the 135 | // application must perform some action before sending a close frame back to 136 | // the session. 137 | func (m *Melody) HandleClose(fn func(*Session, int, string) error) { 138 | if fn != nil { 139 | m.closeHandler = fn 140 | } 141 | } 142 | 143 | // HandleRequest upgrades http requests to websocket connections and dispatches them to be handled by the melody instance. 144 | func (m *Melody) HandleRequest(w http.ResponseWriter, r *http.Request) error { 145 | return m.HandleRequestWithKeys(w, r, nil) 146 | } 147 | 148 | // HandleRequestWithKeys does the same as HandleRequest but populates session.Keys with keys. 149 | func (m *Melody) HandleRequestWithKeys(w http.ResponseWriter, r *http.Request, keys map[string]any) error { 150 | if m.hub.closed() { 151 | return ErrClosed 152 | } 153 | 154 | conn, err := m.Upgrader.Upgrade(w, r, w.Header()) 155 | 156 | if err != nil { 157 | return err 158 | } 159 | 160 | session := &Session{ 161 | Request: r, 162 | Keys: keys, 163 | conn: conn, 164 | output: make(chan envelope, m.Config.MessageBufferSize), 165 | outputDone: make(chan struct{}), 166 | melody: m, 167 | open: true, 168 | rwmutex: &sync.RWMutex{}, 169 | } 170 | 171 | m.hub.register <- session 172 | 173 | m.connectHandler(session) 174 | 175 | go session.writePump() 176 | 177 | session.readPump() 178 | 179 | if !m.hub.closed() { 180 | m.hub.unregister <- session 181 | } 182 | 183 | session.close() 184 | 185 | m.disconnectHandler(session) 186 | 187 | return nil 188 | } 189 | 190 | // Broadcast broadcasts a text message to all sessions. 191 | func (m *Melody) Broadcast(msg []byte) error { 192 | if m.hub.closed() { 193 | return ErrClosed 194 | } 195 | 196 | message := envelope{t: websocket.TextMessage, msg: msg} 197 | m.hub.broadcast <- message 198 | 199 | return nil 200 | } 201 | 202 | // BroadcastFilter broadcasts a text message to all sessions that fn returns true for. 203 | func (m *Melody) BroadcastFilter(msg []byte, fn func(*Session) bool) error { 204 | if m.hub.closed() { 205 | return ErrClosed 206 | } 207 | 208 | message := envelope{t: websocket.TextMessage, msg: msg, filter: fn} 209 | m.hub.broadcast <- message 210 | 211 | return nil 212 | } 213 | 214 | // BroadcastOthers broadcasts a text message to all sessions except session s. 215 | func (m *Melody) BroadcastOthers(msg []byte, s *Session) error { 216 | return m.BroadcastFilter(msg, func(q *Session) bool { 217 | return s != q 218 | }) 219 | } 220 | 221 | // BroadcastMultiple broadcasts a text message to multiple sessions given in the sessions slice. 222 | func (m *Melody) BroadcastMultiple(msg []byte, sessions []*Session) error { 223 | for _, sess := range sessions { 224 | if writeErr := sess.Write(msg); writeErr != nil { 225 | return writeErr 226 | } 227 | } 228 | return nil 229 | } 230 | 231 | // BroadcastBinary broadcasts a binary message to all sessions. 232 | func (m *Melody) BroadcastBinary(msg []byte) error { 233 | if m.hub.closed() { 234 | return ErrClosed 235 | } 236 | 237 | message := envelope{t: websocket.BinaryMessage, msg: msg} 238 | m.hub.broadcast <- message 239 | 240 | return nil 241 | } 242 | 243 | // BroadcastBinaryFilter broadcasts a binary message to all sessions that fn returns true for. 244 | func (m *Melody) BroadcastBinaryFilter(msg []byte, fn func(*Session) bool) error { 245 | if m.hub.closed() { 246 | return ErrClosed 247 | } 248 | 249 | message := envelope{t: websocket.BinaryMessage, msg: msg, filter: fn} 250 | m.hub.broadcast <- message 251 | 252 | return nil 253 | } 254 | 255 | // BroadcastBinaryOthers broadcasts a binary message to all sessions except session s. 256 | func (m *Melody) BroadcastBinaryOthers(msg []byte, s *Session) error { 257 | return m.BroadcastBinaryFilter(msg, func(q *Session) bool { 258 | return s != q 259 | }) 260 | } 261 | 262 | // Sessions returns all sessions. An error is returned if the melody session is closed. 263 | func (m *Melody) Sessions() ([]*Session, error) { 264 | if m.hub.closed() { 265 | return nil, ErrClosed 266 | } 267 | return m.hub.all(), nil 268 | } 269 | 270 | // Close closes the melody instance and all connected sessions. 271 | func (m *Melody) Close() error { 272 | if m.hub.closed() { 273 | return ErrClosed 274 | } 275 | 276 | m.hub.exit <- envelope{t: websocket.CloseMessage, msg: []byte{}} 277 | 278 | return nil 279 | } 280 | 281 | // CloseWithMsg closes the melody instance with the given close payload and all connected sessions. 282 | // Use the FormatCloseMessage function to format a proper close message payload. 283 | func (m *Melody) CloseWithMsg(msg []byte) error { 284 | if m.hub.closed() { 285 | return ErrClosed 286 | } 287 | 288 | m.hub.exit <- envelope{t: websocket.CloseMessage, msg: msg} 289 | 290 | return nil 291 | } 292 | 293 | // Len return the number of connected sessions. 294 | func (m *Melody) Len() int { 295 | return m.hub.len() 296 | } 297 | 298 | // IsClosed returns the status of the melody instance. 299 | func (m *Melody) IsClosed() bool { 300 | return m.hub.closed() 301 | } 302 | 303 | // FormatCloseMessage formats closeCode and text as a WebSocket close message. 304 | func FormatCloseMessage(closeCode int, text string) []byte { 305 | return websocket.FormatCloseMessage(closeCode, text) 306 | } 307 | -------------------------------------------------------------------------------- /melody_test.go: -------------------------------------------------------------------------------- 1 | package melody 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "math/rand" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "sync/atomic" 14 | "testing" 15 | "testing/quick" 16 | "time" 17 | 18 | "github.com/gorilla/websocket" 19 | "github.com/stretchr/testify/assert" 20 | ) 21 | 22 | var TestMsg = []byte("test") 23 | 24 | type TestServer struct { 25 | withKeys bool 26 | m *Melody 27 | } 28 | 29 | func NewTestServerHandler(handler handleMessageFunc) *TestServer { 30 | m := New() 31 | m.HandleMessage(handler) 32 | return &TestServer{ 33 | m: m, 34 | } 35 | } 36 | 37 | func NewTestServer() *TestServer { 38 | m := New() 39 | return &TestServer{ 40 | m: m, 41 | } 42 | } 43 | 44 | func (s *TestServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { 45 | if s.withKeys { 46 | s.m.HandleRequestWithKeys(w, r, make(map[string]any)) 47 | } else { 48 | s.m.HandleRequest(w, r) 49 | } 50 | } 51 | 52 | func NewDialer(url string) (*websocket.Conn, error) { 53 | dialer := &websocket.Dialer{} 54 | conn, _, err := dialer.Dial(strings.Replace(url, "http", "ws", 1), nil) 55 | return conn, err 56 | } 57 | 58 | func MustNewDialer(url string) *websocket.Conn { 59 | conn, err := NewDialer(url) 60 | 61 | if err != nil { 62 | panic("could not dail websocket") 63 | } 64 | 65 | return conn 66 | } 67 | 68 | func TestEcho(t *testing.T) { 69 | ws := NewTestServerHandler(func(session *Session, msg []byte) { 70 | session.Write(msg) 71 | }) 72 | server := httptest.NewServer(ws) 73 | defer server.Close() 74 | 75 | fn := func(msg string) bool { 76 | conn := MustNewDialer(server.URL) 77 | defer conn.Close() 78 | 79 | conn.WriteMessage(websocket.TextMessage, []byte(msg)) 80 | 81 | _, ret, err := conn.ReadMessage() 82 | 83 | assert.Nil(t, err) 84 | 85 | assert.Equal(t, msg, string(ret)) 86 | 87 | return true 88 | } 89 | 90 | err := quick.Check(fn, nil) 91 | 92 | assert.Nil(t, err) 93 | } 94 | 95 | func TestEchoBinary(t *testing.T) { 96 | ws := NewTestServerHandler(func(session *Session, msg []byte) { 97 | session.WriteBinary(msg) 98 | }) 99 | server := httptest.NewServer(ws) 100 | defer server.Close() 101 | 102 | fn := func(msg string) bool { 103 | conn := MustNewDialer(server.URL) 104 | defer conn.Close() 105 | 106 | conn.WriteMessage(websocket.TextMessage, []byte(msg)) 107 | 108 | _, ret, err := conn.ReadMessage() 109 | 110 | assert.Nil(t, err) 111 | 112 | assert.True(t, bytes.Equal([]byte(msg), ret)) 113 | 114 | return true 115 | } 116 | 117 | err := quick.Check(fn, nil) 118 | 119 | assert.Nil(t, err) 120 | } 121 | 122 | func TestWriteClosedServer(t *testing.T) { 123 | done := make(chan bool) 124 | 125 | ws := NewTestServer() 126 | 127 | server := httptest.NewServer(ws) 128 | defer server.Close() 129 | 130 | ws.m.HandleConnect(func(s *Session) { 131 | s.Close() 132 | }) 133 | 134 | ws.m.HandleDisconnect(func(s *Session) { 135 | err := s.Write(TestMsg) 136 | 137 | assert.NotNil(t, err) 138 | close(done) 139 | }) 140 | 141 | conn := MustNewDialer(server.URL) 142 | conn.ReadMessage() 143 | defer conn.Close() 144 | 145 | <-done 146 | } 147 | 148 | func TestWriteClosedClient(t *testing.T) { 149 | done := make(chan bool) 150 | 151 | ws := NewTestServer() 152 | 153 | server := httptest.NewServer(ws) 154 | defer server.Close() 155 | 156 | ws.m.HandleDisconnect(func(s *Session) { 157 | err := s.Write(TestMsg) 158 | 159 | assert.NotNil(t, err) 160 | close(done) 161 | }) 162 | 163 | conn := MustNewDialer(server.URL) 164 | conn.Close() 165 | 166 | <-done 167 | } 168 | 169 | func TestUpgrader(t *testing.T) { 170 | ws := NewTestServer() 171 | ws.m.HandleMessage(func(session *Session, msg []byte) { 172 | session.Write(msg) 173 | }) 174 | 175 | server := httptest.NewServer(ws) 176 | defer server.Close() 177 | 178 | ws.m.Upgrader = &websocket.Upgrader{ 179 | ReadBufferSize: 1024, 180 | WriteBufferSize: 1024, 181 | CheckOrigin: func(r *http.Request) bool { return false }, 182 | } 183 | 184 | _, err := NewDialer(server.URL) 185 | 186 | assert.ErrorIs(t, err, websocket.ErrBadHandshake) 187 | } 188 | 189 | func TestBroadcast(t *testing.T) { 190 | n := 10 191 | msg := "test" 192 | 193 | test := func(h func(*TestServer), w func(*websocket.Conn)) { 194 | 195 | ws := NewTestServer() 196 | 197 | h(ws) 198 | 199 | server := httptest.NewServer(ws) 200 | defer server.Close() 201 | 202 | conn := MustNewDialer(server.URL) 203 | defer conn.Close() 204 | 205 | listeners := make([]*websocket.Conn, n) 206 | for i := range listeners { 207 | listener := MustNewDialer(server.URL) 208 | listeners[i] = listener 209 | defer listeners[i].Close() 210 | } 211 | 212 | w(conn) 213 | 214 | for _, listener := range listeners { 215 | _, ret, err := listener.ReadMessage() 216 | 217 | assert.Nil(t, err) 218 | 219 | assert.Equal(t, msg, string(ret)) 220 | } 221 | } 222 | 223 | test(func(ws *TestServer) { 224 | ws.m.HandleMessage(func(s *Session, msg []byte) { 225 | ws.m.Broadcast(msg) 226 | }) 227 | }, func(conn *websocket.Conn) { 228 | conn.WriteMessage(websocket.TextMessage, []byte(msg)) 229 | }) 230 | 231 | test(func(ws *TestServer) { 232 | ws.m.HandleMessageBinary(func(s *Session, msg []byte) { 233 | ws.m.BroadcastBinary(msg) 234 | }) 235 | }, func(conn *websocket.Conn) { 236 | conn.WriteMessage(websocket.BinaryMessage, []byte(msg)) 237 | }) 238 | 239 | test(func(ws *TestServer) { 240 | ws.m.HandleMessage(func(s *Session, msg []byte) { 241 | ws.m.BroadcastFilter(msg, func(s *Session) bool { 242 | return true 243 | }) 244 | }) 245 | }, func(conn *websocket.Conn) { 246 | conn.WriteMessage(websocket.TextMessage, []byte(msg)) 247 | }) 248 | 249 | test(func(ws *TestServer) { 250 | ws.m.HandleMessageBinary(func(s *Session, msg []byte) { 251 | ws.m.BroadcastBinaryFilter(msg, func(s *Session) bool { 252 | return true 253 | }) 254 | }) 255 | }, func(conn *websocket.Conn) { 256 | conn.WriteMessage(websocket.BinaryMessage, []byte(msg)) 257 | }) 258 | 259 | test(func(ws *TestServer) { 260 | ws.m.HandleMessage(func(s *Session, msg []byte) { 261 | ws.m.BroadcastOthers(msg, s) 262 | }) 263 | }, func(conn *websocket.Conn) { 264 | conn.WriteMessage(websocket.TextMessage, []byte(msg)) 265 | }) 266 | 267 | test(func(ws *TestServer) { 268 | ws.m.HandleMessageBinary(func(s *Session, msg []byte) { 269 | ws.m.BroadcastBinaryOthers(msg, s) 270 | }) 271 | }, func(conn *websocket.Conn) { 272 | conn.WriteMessage(websocket.BinaryMessage, []byte(msg)) 273 | }) 274 | 275 | test(func(ws *TestServer) { 276 | ws.m.HandleMessage(func(s *Session, msg []byte) { 277 | ss, _ := ws.m.Sessions() 278 | ws.m.BroadcastMultiple(msg, ss) 279 | }) 280 | }, func(conn *websocket.Conn) { 281 | conn.WriteMessage(websocket.TextMessage, []byte(msg)) 282 | }) 283 | } 284 | 285 | func TestClose(t *testing.T) { 286 | ws := NewTestServer() 287 | 288 | server := httptest.NewServer(ws) 289 | defer server.Close() 290 | 291 | n := 10 292 | 293 | conns := make([]*websocket.Conn, n) 294 | for i := range conns { 295 | conn := MustNewDialer(server.URL) 296 | conns[i] = conn 297 | defer conns[i].Close() 298 | } 299 | 300 | q := make(chan bool) 301 | ws.m.HandleDisconnect(func(s *Session) { 302 | q <- true 303 | }) 304 | 305 | ws.m.Close() 306 | 307 | for _, conn := range conns { 308 | conn.ReadMessage() 309 | } 310 | 311 | assert.Zero(t, ws.m.Len()) 312 | 313 | m := 0 314 | for range q { 315 | m += 1 316 | if m == n { 317 | break 318 | } 319 | } 320 | } 321 | 322 | func TestLen(t *testing.T) { 323 | rand.Seed(time.Now().UnixNano()) 324 | 325 | connect := int(rand.Int31n(100)) 326 | disconnect := rand.Float32() 327 | conns := make([]*websocket.Conn, connect) 328 | 329 | defer func() { 330 | for _, conn := range conns { 331 | if conn != nil { 332 | conn.Close() 333 | } 334 | } 335 | }() 336 | 337 | ws := NewTestServer() 338 | 339 | server := httptest.NewServer(ws) 340 | defer server.Close() 341 | 342 | disconnected := 0 343 | for i := 0; i < connect; i++ { 344 | conn := MustNewDialer(server.URL) 345 | 346 | if rand.Float32() < disconnect { 347 | conns[i] = nil 348 | disconnected++ 349 | conn.Close() 350 | continue 351 | } 352 | 353 | conns[i] = conn 354 | } 355 | 356 | time.Sleep(time.Millisecond) 357 | 358 | connected := connect - disconnected 359 | 360 | assert.Equal(t, ws.m.Len(), connected) 361 | } 362 | 363 | func TestSessions(t *testing.T) { 364 | rand.Seed(time.Now().UnixNano()) 365 | 366 | connect := int(rand.Int31n(100)) 367 | disconnect := rand.Float32() 368 | conns := make([]*websocket.Conn, connect) 369 | defer func() { 370 | for _, conn := range conns { 371 | if conn != nil { 372 | conn.Close() 373 | } 374 | } 375 | }() 376 | 377 | ws := NewTestServer() 378 | server := httptest.NewServer(ws) 379 | defer server.Close() 380 | 381 | disconnected := 0 382 | for i := 0; i < connect; i++ { 383 | conn, err := NewDialer(server.URL) 384 | 385 | if err != nil { 386 | t.Error(err) 387 | } 388 | 389 | if rand.Float32() < disconnect { 390 | conns[i] = nil 391 | disconnected++ 392 | conn.Close() 393 | continue 394 | } 395 | 396 | conns[i] = conn 397 | } 398 | 399 | time.Sleep(time.Millisecond) 400 | 401 | connected := connect - disconnected 402 | 403 | ss, err := ws.m.Sessions() 404 | 405 | assert.Nil(t, err) 406 | 407 | assert.Equal(t, len(ss), connected) 408 | } 409 | 410 | func TestPingPong(t *testing.T) { 411 | done := make(chan bool) 412 | 413 | ws := NewTestServer() 414 | ws.m.Config.PingPeriod = time.Millisecond 415 | 416 | ws.m.HandlePong(func(s *Session) { 417 | close(done) 418 | }) 419 | 420 | server := httptest.NewServer(ws) 421 | defer server.Close() 422 | 423 | conn := MustNewDialer(server.URL) 424 | defer conn.Close() 425 | 426 | go conn.NextReader() 427 | 428 | <-done 429 | } 430 | 431 | func TestHandleClose(t *testing.T) { 432 | done := make(chan bool) 433 | 434 | ws := NewTestServer() 435 | ws.m.Config.PingPeriod = time.Millisecond 436 | 437 | ws.m.HandleClose(func(s *Session, code int, text string) error { 438 | close(done) 439 | return nil 440 | }) 441 | 442 | server := httptest.NewServer(ws) 443 | defer server.Close() 444 | 445 | conn := MustNewDialer(server.URL) 446 | 447 | conn.WriteMessage(websocket.CloseMessage, nil) 448 | 449 | <-done 450 | } 451 | 452 | func TestHandleError(t *testing.T) { 453 | done := make(chan bool) 454 | 455 | ws := NewTestServer() 456 | 457 | ws.m.HandleError(func(s *Session, err error) { 458 | var closeError *websocket.CloseError 459 | assert.ErrorAs(t, err, &closeError) 460 | close(done) 461 | }) 462 | 463 | server := httptest.NewServer(ws) 464 | defer server.Close() 465 | 466 | conn := MustNewDialer(server.URL) 467 | 468 | conn.Close() 469 | 470 | <-done 471 | } 472 | 473 | func TestHandleErrorWrite(t *testing.T) { 474 | writeError := make(chan struct{}) 475 | disconnect := make(chan struct{}) 476 | 477 | ws := NewTestServer() 478 | ws.m.Config.WriteWait = 0 479 | 480 | ws.m.HandleConnect(func(s *Session) { 481 | err := s.Write(TestMsg) 482 | assert.Nil(t, err) 483 | }) 484 | 485 | ws.m.HandleError(func(s *Session, err error) { 486 | assert.NotNil(t, err) 487 | 488 | if os.IsTimeout(err) { 489 | close(writeError) 490 | } 491 | }) 492 | 493 | ws.m.HandleDisconnect(func(s *Session) { 494 | close(disconnect) 495 | }) 496 | 497 | server := httptest.NewServer(ws) 498 | defer server.Close() 499 | 500 | conn := MustNewDialer(server.URL) 501 | defer conn.Close() 502 | 503 | go conn.NextReader() 504 | 505 | <-writeError 506 | <-disconnect 507 | } 508 | 509 | func TestErrClosed(t *testing.T) { 510 | res := make(chan *Session) 511 | 512 | ws := NewTestServer() 513 | 514 | ws.m.HandleConnect(func(s *Session) { 515 | ws.m.CloseWithMsg(TestMsg) 516 | }) 517 | 518 | ws.m.HandleDisconnect(func(s *Session) { 519 | res <- s 520 | }) 521 | 522 | server := httptest.NewServer(ws) 523 | defer server.Close() 524 | 525 | conn := MustNewDialer(server.URL) 526 | defer conn.Close() 527 | 528 | go conn.ReadMessage() 529 | 530 | s := <-res 531 | 532 | assert.True(t, s.IsClosed()) 533 | assert.True(t, ws.m.IsClosed()) 534 | _, err := ws.m.Sessions() 535 | assert.ErrorIs(t, err, ErrClosed) 536 | assert.ErrorIs(t, ws.m.Close(), ErrClosed) 537 | assert.ErrorIs(t, ws.m.CloseWithMsg(TestMsg), ErrClosed) 538 | 539 | assert.ErrorIs(t, ws.m.Broadcast(TestMsg), ErrClosed) 540 | assert.ErrorIs(t, ws.m.BroadcastBinary(TestMsg), ErrClosed) 541 | assert.ErrorIs(t, ws.m.BroadcastFilter(TestMsg, func(s *Session) bool { return true }), ErrClosed) 542 | assert.ErrorIs(t, ws.m.BroadcastBinaryFilter(TestMsg, func(s *Session) bool { return true }), ErrClosed) 543 | assert.ErrorIs(t, ws.m.HandleRequest(nil, nil), ErrClosed) 544 | } 545 | 546 | func TestErrSessionClosed(t *testing.T) { 547 | res := make(chan *Session) 548 | 549 | ws := NewTestServer() 550 | 551 | ws.m.HandleConnect(func(s *Session) { 552 | s.CloseWithMsg(TestMsg) 553 | }) 554 | 555 | ws.m.HandleDisconnect(func(s *Session) { 556 | res <- s 557 | }) 558 | 559 | server := httptest.NewServer(ws) 560 | defer server.Close() 561 | 562 | conn := MustNewDialer(server.URL) 563 | defer conn.Close() 564 | 565 | go conn.ReadMessage() 566 | 567 | s := <-res 568 | 569 | assert.True(t, s.IsClosed()) 570 | assert.ErrorIs(t, s.Write(TestMsg), ErrSessionClosed) 571 | assert.ErrorIs(t, s.WriteBinary(TestMsg), ErrSessionClosed) 572 | assert.ErrorIs(t, s.CloseWithMsg(TestMsg), ErrSessionClosed) 573 | assert.ErrorIs(t, s.Close(), ErrSessionClosed) 574 | assert.ErrorIs(t, ws.m.BroadcastMultiple(TestMsg, []*Session{s}), ErrSessionClosed) 575 | 576 | assert.ErrorIs(t, s.writeRaw(envelope{}), ErrWriteClosed) 577 | s.writeMessage(envelope{}) 578 | } 579 | 580 | func TestErrMessageBufferFull(t *testing.T) { 581 | done := make(chan bool) 582 | 583 | ws := NewTestServerHandler(func(session *Session, msg []byte) { 584 | session.Write(msg) 585 | session.Write(msg) 586 | }) 587 | ws.m.Config.MessageBufferSize = 0 588 | ws.m.HandleError(func(s *Session, err error) { 589 | if errors.Is(err, ErrMessageBufferFull) { 590 | close(done) 591 | } 592 | }) 593 | server := httptest.NewServer(ws) 594 | defer server.Close() 595 | 596 | conn := MustNewDialer(server.URL) 597 | defer conn.Close() 598 | 599 | conn.WriteMessage(websocket.TextMessage, TestMsg) 600 | 601 | <-done 602 | } 603 | 604 | func TestSessionKeys(t *testing.T) { 605 | ws := NewTestServer() 606 | 607 | ws.m.HandleConnect(func(session *Session) { 608 | session.Set("stamp", time.Now().UnixNano()) 609 | }) 610 | ws.m.HandleMessage(func(session *Session, msg []byte) { 611 | stamp := session.MustGet("stamp").(int64) 612 | session.Write([]byte(strconv.Itoa(int(stamp)))) 613 | }) 614 | server := httptest.NewServer(ws) 615 | defer server.Close() 616 | 617 | fn := func(msg string) bool { 618 | conn := MustNewDialer(server.URL) 619 | defer conn.Close() 620 | 621 | conn.WriteMessage(websocket.TextMessage, []byte(msg)) 622 | 623 | _, ret, err := conn.ReadMessage() 624 | 625 | assert.Nil(t, err) 626 | 627 | stamp, err := strconv.Atoi(string(ret)) 628 | 629 | assert.Nil(t, err) 630 | 631 | diff := int(time.Now().UnixNano()) - stamp 632 | 633 | assert.Greater(t, diff, 0) 634 | 635 | return true 636 | } 637 | 638 | assert.Nil(t, quick.Check(fn, nil)) 639 | } 640 | 641 | func TestSessionKeysConcurrent(t *testing.T) { 642 | ss := make(chan *Session) 643 | 644 | ws := NewTestServer() 645 | 646 | ws.m.HandleConnect(func(s *Session) { 647 | ss <- s 648 | }) 649 | 650 | server := httptest.NewServer(ws) 651 | defer server.Close() 652 | 653 | conn := MustNewDialer(server.URL) 654 | defer conn.Close() 655 | 656 | s := <-ss 657 | 658 | var wg sync.WaitGroup 659 | 660 | for i := 0; i < 100; i++ { 661 | wg.Add(1) 662 | 663 | go func() { 664 | s.Set("test", TestMsg) 665 | 666 | v1, exists := s.Get("test") 667 | 668 | assert.True(t, exists) 669 | assert.Equal(t, v1, TestMsg) 670 | 671 | v2 := s.MustGet("test") 672 | 673 | assert.Equal(t, v1, v2) 674 | 675 | wg.Done() 676 | }() 677 | } 678 | 679 | wg.Wait() 680 | 681 | for i := 0; i < 100; i++ { 682 | wg.Add(1) 683 | 684 | go func() { 685 | s.UnSet("test") 686 | 687 | _, exists := s.Get("test") 688 | 689 | assert.False(t, exists) 690 | 691 | wg.Done() 692 | }() 693 | } 694 | 695 | wg.Wait() 696 | } 697 | 698 | func TestMisc(t *testing.T) { 699 | res := make(chan *Session) 700 | 701 | ws := NewTestServer() 702 | 703 | ws.m.HandleConnect(func(s *Session) { 704 | res <- s 705 | }) 706 | 707 | server := httptest.NewServer(ws) 708 | defer server.Close() 709 | 710 | conn := MustNewDialer(server.URL) 711 | defer conn.Close() 712 | 713 | go conn.ReadMessage() 714 | 715 | s := <-res 716 | 717 | assert.Contains(t, s.LocalAddr().String(), "127.0.0.1") 718 | assert.Contains(t, s.RemoteAddr().String(), "127.0.0.1") 719 | assert.Equal(t, FormatCloseMessage(websocket.CloseMessage, "test"), websocket.FormatCloseMessage(websocket.CloseMessage, "test")) 720 | assert.Panics(t, func() { 721 | s.MustGet("test") 722 | }) 723 | } 724 | 725 | func TestHandleSentMessage(t *testing.T) { 726 | test := func(h func(*TestServer, chan bool), w func(*websocket.Conn)) { 727 | done := make(chan bool) 728 | 729 | ws := NewTestServer() 730 | server := httptest.NewServer(ws) 731 | defer server.Close() 732 | 733 | h(ws, done) 734 | 735 | conn := MustNewDialer(server.URL) 736 | defer conn.Close() 737 | 738 | w(conn) 739 | 740 | <-done 741 | } 742 | 743 | test(func(ws *TestServer, done chan bool) { 744 | ws.m.HandleMessage(func(s *Session, msg []byte) { 745 | s.Write(msg) 746 | }) 747 | 748 | ws.m.HandleSentMessage(func(s *Session, msg []byte) { 749 | assert.Equal(t, TestMsg, msg) 750 | close(done) 751 | }) 752 | }, func(conn *websocket.Conn) { 753 | conn.WriteMessage(websocket.TextMessage, TestMsg) 754 | }) 755 | 756 | test(func(ws *TestServer, done chan bool) { 757 | ws.m.HandleMessageBinary(func(s *Session, msg []byte) { 758 | s.WriteBinary(msg) 759 | }) 760 | 761 | ws.m.HandleSentMessageBinary(func(s *Session, msg []byte) { 762 | assert.Equal(t, TestMsg, msg) 763 | close(done) 764 | }) 765 | }, func(conn *websocket.Conn) { 766 | conn.WriteMessage(websocket.BinaryMessage, TestMsg) 767 | }) 768 | } 769 | 770 | func TestConcurrentMessageHandling(t *testing.T) { 771 | testTimeout := func(cmh bool, msgType int) bool { 772 | base := time.Millisecond * 100 773 | done := make(chan struct{}) 774 | 775 | handler := func(s *Session, msg []byte) { 776 | if len(msg) == 0 { 777 | done <- struct{}{} 778 | return 779 | } 780 | 781 | time.Sleep(base * 2) 782 | } 783 | 784 | ws := NewTestServerHandler(func(session *Session, msg []byte) {}) 785 | if msgType == websocket.TextMessage { 786 | ws.m.HandleMessage(handler) 787 | } else { 788 | ws.m.HandleMessageBinary(handler) 789 | } 790 | 791 | ws.m.Config.ConcurrentMessageHandling = cmh 792 | ws.m.Config.PongWait = base 793 | 794 | var errorSet atomic.Bool 795 | ws.m.HandleError(func(s *Session, err error) { 796 | errorSet.Store(true) 797 | done <- struct{}{} 798 | }) 799 | 800 | server := httptest.NewServer(ws) 801 | defer server.Close() 802 | 803 | conn := MustNewDialer(server.URL) 804 | defer conn.Close() 805 | 806 | conn.WriteMessage(msgType, TestMsg) 807 | conn.WriteMessage(msgType, TestMsg) 808 | 809 | time.Sleep(base / 4) 810 | 811 | conn.WriteMessage(msgType, nil) 812 | 813 | <-done 814 | 815 | return errorSet.Load() 816 | } 817 | 818 | t.Run("text should error", func(t *testing.T) { 819 | errorSet := testTimeout(false, websocket.TextMessage) 820 | 821 | if !errorSet { 822 | t.FailNow() 823 | } 824 | }) 825 | 826 | t.Run("text should not error", func(t *testing.T) { 827 | errorSet := testTimeout(true, websocket.TextMessage) 828 | 829 | if errorSet { 830 | t.FailNow() 831 | } 832 | }) 833 | 834 | t.Run("binary should error", func(t *testing.T) { 835 | errorSet := testTimeout(false, websocket.BinaryMessage) 836 | 837 | if !errorSet { 838 | t.FailNow() 839 | } 840 | }) 841 | 842 | t.Run("binary should not error", func(t *testing.T) { 843 | errorSet := testTimeout(true, websocket.BinaryMessage) 844 | 845 | if errorSet { 846 | t.FailNow() 847 | } 848 | }) 849 | } 850 | -------------------------------------------------------------------------------- /session.go: -------------------------------------------------------------------------------- 1 | package melody 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "sync" 7 | "time" 8 | 9 | "github.com/gorilla/websocket" 10 | ) 11 | 12 | // Session wrapper around websocket connections. 13 | type Session struct { 14 | Request *http.Request 15 | Keys map[string]any 16 | conn *websocket.Conn 17 | output chan envelope 18 | outputDone chan struct{} 19 | melody *Melody 20 | open bool 21 | rwmutex *sync.RWMutex 22 | } 23 | 24 | func (s *Session) writeMessage(message envelope) { 25 | if s.closed() { 26 | s.melody.errorHandler(s, ErrWriteClosed) 27 | return 28 | } 29 | 30 | select { 31 | case s.output <- message: 32 | default: 33 | s.melody.errorHandler(s, ErrMessageBufferFull) 34 | } 35 | } 36 | 37 | func (s *Session) writeRaw(message envelope) error { 38 | if s.closed() { 39 | return ErrWriteClosed 40 | } 41 | 42 | s.conn.SetWriteDeadline(time.Now().Add(s.melody.Config.WriteWait)) 43 | err := s.conn.WriteMessage(message.t, message.msg) 44 | 45 | if err != nil { 46 | return err 47 | } 48 | 49 | return nil 50 | } 51 | 52 | func (s *Session) closed() bool { 53 | s.rwmutex.RLock() 54 | defer s.rwmutex.RUnlock() 55 | 56 | return !s.open 57 | } 58 | 59 | func (s *Session) close() { 60 | s.rwmutex.Lock() 61 | open := s.open 62 | s.open = false 63 | s.rwmutex.Unlock() 64 | if open { 65 | s.conn.Close() 66 | close(s.outputDone) 67 | } 68 | } 69 | 70 | func (s *Session) ping() { 71 | s.writeRaw(envelope{t: websocket.PingMessage, msg: []byte{}}) 72 | } 73 | 74 | func (s *Session) writePump() { 75 | ticker := time.NewTicker(s.melody.Config.PingPeriod) 76 | defer ticker.Stop() 77 | 78 | loop: 79 | for { 80 | select { 81 | case msg := <-s.output: 82 | err := s.writeRaw(msg) 83 | 84 | if err != nil { 85 | s.melody.errorHandler(s, err) 86 | break loop 87 | } 88 | 89 | if msg.t == websocket.CloseMessage { 90 | break loop 91 | } 92 | 93 | if msg.t == websocket.TextMessage { 94 | s.melody.messageSentHandler(s, msg.msg) 95 | } 96 | 97 | if msg.t == websocket.BinaryMessage { 98 | s.melody.messageSentHandlerBinary(s, msg.msg) 99 | } 100 | case <-ticker.C: 101 | s.ping() 102 | case _, ok := <-s.outputDone: 103 | if !ok { 104 | break loop 105 | } 106 | } 107 | } 108 | 109 | s.close() 110 | } 111 | 112 | func (s *Session) readPump() { 113 | s.conn.SetReadLimit(s.melody.Config.MaxMessageSize) 114 | s.conn.SetReadDeadline(time.Now().Add(s.melody.Config.PongWait)) 115 | 116 | s.conn.SetPongHandler(func(string) error { 117 | s.conn.SetReadDeadline(time.Now().Add(s.melody.Config.PongWait)) 118 | s.melody.pongHandler(s) 119 | return nil 120 | }) 121 | 122 | if s.melody.closeHandler != nil { 123 | s.conn.SetCloseHandler(func(code int, text string) error { 124 | return s.melody.closeHandler(s, code, text) 125 | }) 126 | } 127 | 128 | for { 129 | t, message, err := s.conn.ReadMessage() 130 | 131 | if err != nil { 132 | s.melody.errorHandler(s, err) 133 | break 134 | } 135 | 136 | if s.melody.Config.ConcurrentMessageHandling { 137 | go s.handleMessage(t, message) 138 | } else { 139 | s.handleMessage(t, message) 140 | } 141 | } 142 | } 143 | 144 | func (s *Session) handleMessage(t int, message []byte) { 145 | switch t { 146 | case websocket.TextMessage: 147 | s.melody.messageHandler(s, message) 148 | case websocket.BinaryMessage: 149 | s.melody.messageHandlerBinary(s, message) 150 | } 151 | } 152 | 153 | // Write writes message to session. 154 | func (s *Session) Write(msg []byte) error { 155 | if s.closed() { 156 | return ErrSessionClosed 157 | } 158 | 159 | s.writeMessage(envelope{t: websocket.TextMessage, msg: msg}) 160 | 161 | return nil 162 | } 163 | 164 | // WriteBinary writes a binary message to session. 165 | func (s *Session) WriteBinary(msg []byte) error { 166 | if s.closed() { 167 | return ErrSessionClosed 168 | } 169 | 170 | s.writeMessage(envelope{t: websocket.BinaryMessage, msg: msg}) 171 | 172 | return nil 173 | } 174 | 175 | // Close closes session. 176 | func (s *Session) Close() error { 177 | if s.closed() { 178 | return ErrSessionClosed 179 | } 180 | 181 | s.writeMessage(envelope{t: websocket.CloseMessage, msg: []byte{}}) 182 | 183 | return nil 184 | } 185 | 186 | // CloseWithMsg closes the session with the provided payload. 187 | // Use the FormatCloseMessage function to format a proper close message payload. 188 | func (s *Session) CloseWithMsg(msg []byte) error { 189 | if s.closed() { 190 | return ErrSessionClosed 191 | } 192 | 193 | s.writeMessage(envelope{t: websocket.CloseMessage, msg: msg}) 194 | 195 | return nil 196 | } 197 | 198 | // Set is used to store a new key/value pair exclusively for this session. 199 | // It also lazy initializes s.Keys if it was not used previously. 200 | func (s *Session) Set(key string, value any) { 201 | s.rwmutex.Lock() 202 | defer s.rwmutex.Unlock() 203 | 204 | if s.Keys == nil { 205 | s.Keys = make(map[string]any) 206 | } 207 | 208 | s.Keys[key] = value 209 | } 210 | 211 | // Get returns the value for the given key, ie: (value, true). 212 | // If the value does not exists it returns (nil, false) 213 | func (s *Session) Get(key string) (value any, exists bool) { 214 | s.rwmutex.RLock() 215 | defer s.rwmutex.RUnlock() 216 | 217 | if s.Keys != nil { 218 | value, exists = s.Keys[key] 219 | } 220 | 221 | return 222 | } 223 | 224 | // MustGet returns the value for the given key if it exists, otherwise it panics. 225 | func (s *Session) MustGet(key string) any { 226 | if value, exists := s.Get(key); exists { 227 | return value 228 | } 229 | 230 | panic("Key \"" + key + "\" does not exist") 231 | } 232 | 233 | // UnSet will delete the key and has no return value 234 | func (s *Session) UnSet(key string) { 235 | s.rwmutex.Lock() 236 | defer s.rwmutex.Unlock() 237 | if s.Keys != nil { 238 | delete(s.Keys, key) 239 | } 240 | } 241 | 242 | // IsClosed returns the status of the connection. 243 | func (s *Session) IsClosed() bool { 244 | return s.closed() 245 | } 246 | 247 | // LocalAddr returns the local addr of the connection. 248 | func (s *Session) LocalAddr() net.Addr { 249 | return s.conn.LocalAddr() 250 | } 251 | 252 | // RemoteAddr returns the remote addr of the connection. 253 | func (s *Session) RemoteAddr() net.Addr { 254 | return s.conn.RemoteAddr() 255 | } 256 | 257 | // WebsocketConnection returns the underlying websocket connection. 258 | // This can be used to e.g. set/read additional websocket options or to write sychronous messages. 259 | func (s *Session) WebsocketConnection() *websocket.Conn { 260 | return s.conn 261 | } 262 | --------------------------------------------------------------------------------