├── .gitignore ├── goatee.go ├── fixture └── development.json ├── example ├── config │ └── development.json ├── main.go ├── chan1.html ├── chan2.html └── js │ └── goatee.js ├── pubsub_test.go ├── .travis.yml ├── goatee_test.go ├── LICENSE ├── config.go ├── pubsub.go ├── README.md ├── connection.go └── notifier.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store -------------------------------------------------------------------------------- /goatee.go: -------------------------------------------------------------------------------- 1 | package goatee 2 | -------------------------------------------------------------------------------- /fixture/development.json: -------------------------------------------------------------------------------- 1 | { 2 | "redis": { 3 | "host": "localhost:6379" 4 | }, 5 | "web": { 6 | "host": "localhost:1235" 7 | } 8 | } -------------------------------------------------------------------------------- /example/config/development.json: -------------------------------------------------------------------------------- 1 | { 2 | "redis": { 3 | "host": "localhost:6379" 4 | }, 5 | "web": { 6 | "host": "localhost:1235" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /pubsub_test.go: -------------------------------------------------------------------------------- 1 | package goatee 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestPubsubHub(t *testing.T) { 8 | client := setup(t) 9 | defer client.Close() 10 | } 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: "1.10" 3 | services: 4 | - redis-server 5 | install: 6 | - go get -v github.com/gomodule/redigo/redis 7 | - go get -v github.com/gorilla/websocket 8 | script: 9 | - go test github.com/johnernaut/goatee -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/johnernaut/goatee" 8 | ) 9 | 10 | func main() { 11 | server := goatee.CreateServer() 12 | server.RegisterAuthFunc(Authenticate) 13 | server.StartServer() 14 | } 15 | 16 | func Authenticate(req *http.Request) bool { 17 | vals := req.URL.Query() 18 | 19 | if vals.Get("api_key") == "ABC123" { 20 | log.Println(vals.Get("api_key")) 21 | 22 | return true 23 | } 24 | 25 | return false 26 | } 27 | -------------------------------------------------------------------------------- /goatee_test.go: -------------------------------------------------------------------------------- 1 | package goatee 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | type testData struct { 8 | host string 9 | subchan []string 10 | } 11 | 12 | func setup(t *testing.T) *RedisClient { 13 | client := setupRedisConnection(t) 14 | setupConfig(t) 15 | 16 | return client 17 | } 18 | 19 | func setupRedisConnection(t *testing.T) *RedisClient { 20 | data := testData{host: ":6379"} 21 | client, err := NewRedisClient(data.host) 22 | if err != nil { 23 | t.Errorf("Error creating Redis client: %s", err) 24 | } 25 | 26 | return client 27 | } 28 | 29 | func setupConfig(t *testing.T) { 30 | LoadConfig("fixture") 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2014 John Johnson 2 | All Rights Reserved. 3 | 4 | MIT LICENSE 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of 7 | this software and associated documentation files (the "Software"), to deal in 8 | the Software without restriction, including without limitation the rights to 9 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 10 | the Software, and to permit persons to whom the Software is furnished to do so, 11 | subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 18 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 19 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 20 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 21 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package goatee 2 | 3 | import ( 4 | "encoding/json" 5 | "io/ioutil" 6 | "log" 7 | "os" 8 | ) 9 | 10 | type configuration struct { 11 | Redis Redis 12 | Web Web 13 | } 14 | 15 | type Redis struct { 16 | Host string 17 | } 18 | 19 | type Web struct { 20 | Host string 21 | } 22 | 23 | var ( 24 | DEBUG = false 25 | Config = new(configuration) 26 | ) 27 | 28 | func getEnv() string { 29 | env := os.Getenv("GO_ENV") 30 | if env == "" || env == "development" { 31 | DEBUG = true 32 | return "development" 33 | } 34 | return env 35 | } 36 | 37 | func LoadConfig(path string) *configuration { 38 | var file []byte 39 | var err error 40 | var paths = []string{os.Getenv("HOME") + "/.config/goatee", "/etc/goatee"} 41 | 42 | // If path is defined, prepend it to paths 43 | if len(path) > 0 { 44 | paths = append([]string{path}, paths...) 45 | } 46 | 47 | // Try to find a config file to use 48 | found := false 49 | for _, path := range paths { 50 | file, err = ioutil.ReadFile(path + string(os.PathSeparator) + getEnv() + ".json") 51 | if err == nil { 52 | log.Printf("Reading configuration from: %s", path) 53 | found = true 54 | break 55 | } 56 | } 57 | 58 | if !found { 59 | log.Fatalf("Error reading config file.") 60 | } 61 | 62 | err = json.Unmarshal(file, &Config) 63 | if err != nil { 64 | log.Fatalf("Error parsing JSON: %s", err.Error()) 65 | } 66 | 67 | return Config 68 | } 69 | -------------------------------------------------------------------------------- /example/chan1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | YERP 5 | 42 | 43 | 44 |

Websocket Messages:

45 | 47 | 48 |
49 | 50 | 51 |
52 | 53 | 54 | 55 | 69 | 70 | -------------------------------------------------------------------------------- /example/chan2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | YERP 5 | 42 | 43 | 44 |

Websocket Messages:

45 | 47 | 48 |
49 | 50 | 51 |
52 | 53 | 54 | 55 | 69 | 70 | -------------------------------------------------------------------------------- /pubsub.go: -------------------------------------------------------------------------------- 1 | package goatee 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "strconv" 7 | "sync" 8 | "time" 9 | 10 | "github.com/gomodule/redigo/redis" 11 | ) 12 | 13 | type Message struct { 14 | Type string 15 | Channel string 16 | Data []byte 17 | } 18 | 19 | type Data struct { 20 | Channel string `json:"channel"` 21 | Payload string `json:"payload"` 22 | CreatedAt string `json:"created_at"` 23 | } 24 | 25 | type RedisClient struct { 26 | conn redis.Conn 27 | redis.PubSubConn 28 | sync.Mutex 29 | } 30 | 31 | type Client interface { 32 | Receive() (message Message) 33 | } 34 | 35 | func NewRedisClient(host string) (*RedisClient, error) { 36 | conn, err := redis.Dial("tcp", host) 37 | if err != nil { 38 | log.Printf("Error dialing redis pubsub: %s", err) 39 | return nil, err 40 | } 41 | 42 | pubsub, _ := redis.Dial("tcp", host) 43 | client := RedisClient{conn, redis.PubSubConn{pubsub}, sync.Mutex{}} 44 | 45 | if DEBUG { 46 | log.Println("Subscribed to Redis on: ", host) 47 | } 48 | 49 | go func() { 50 | for { 51 | time.Sleep(200 * time.Millisecond) 52 | client.Lock() 53 | client.conn.Flush() 54 | client.Unlock() 55 | } 56 | }() 57 | 58 | go client.PubsubHub() 59 | 60 | h.rclient = &client 61 | h.rconn = conn 62 | 63 | return &client, nil 64 | } 65 | 66 | func (client *RedisClient) Receive() Message { 67 | switch message := client.PubSubConn.Receive().(type) { 68 | case redis.Message: 69 | return Message{"message", message.Channel, message.Data} 70 | case redis.Subscription: 71 | return Message{message.Kind, message.Channel, []byte(strconv.Itoa(message.Count))} 72 | } 73 | return Message{} 74 | } 75 | 76 | func (client *RedisClient) PubsubHub() { 77 | data := Data{} 78 | for { 79 | message := client.Receive() 80 | if message.Type == "message" { 81 | err := json.Unmarshal(message.Data, &data) 82 | if err != nil { 83 | log.Println("Error parsing payload JSON: ", err) 84 | } 85 | 86 | data.Channel = message.Channel 87 | 88 | h.broadcast <- &data 89 | if DEBUG { 90 | log.Printf("Received: %s", message) 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | goatee 2 | ====== 3 | 4 | A Redis-backed notification server written in Go. 5 | 6 | [![Build Status](https://travis-ci.org/johnernaut/goatee.png?branch=master)](https://travis-ci.org/johnernaut/goatee) 7 | 8 | **Client library:** [goatee.js](https://github.com/johnernaut/goatee.js) 9 | 10 | ## Installation 11 | `go get github.com/johnernaut/goatee` 12 | 13 | `import "github.com/johnernaut/goatee"` 14 | 15 | ## Usage 16 | **goatee** works by listening on a channel via [Redis Pub/Sub](http://redis.io/topics/pubsub) and then sending the received message to connected clients via [WebSockets](http://en.wikipedia.org/wiki/WebSocket). Clients may create channels to listen on by using the [goatee client library](https://github.com/johnernaut/goatee.js). 17 | 18 | ### Configuration 19 | **goatee** will look for a JSON configuration file in a `config` folder at the root of your project with the following names based on your environment: `development.json`, `production.json`, `etc`. By default `config/development.json` will be used but you can also specify a `GO_ENV` environment variable and the name of that will be used instead. 20 | 21 | ```javascript 22 | // example json configuration 23 | // specify redis and websocket hosts 24 | { 25 | "redis": { 26 | "host": "localhost:6379" 27 | }, 28 | "web": { 29 | "host": "localhost:1235" 30 | } 31 | } 32 | ``` 33 | 34 | ### Server 35 | ```go 36 | package main 37 | 38 | import ( 39 | "github.com/johnernaut/goatee" 40 | "log" 41 | ) 42 | 43 | func main() { 44 | // subscribe to one or many redis channels 45 | err := goatee.CreateServer() 46 | 47 | if err != nil { 48 | log.Fatal("Error: ", err.Error()) 49 | } 50 | } 51 | ``` 52 | 53 | ### Client 54 | An example of how to use the [goatee client library](https://github.com/johnernaut/goatee.js) can be found in the `examples` folder. 55 | 56 | 57 | ### Redis 58 | With **goatee** running and your web browser connected to the socket, you should now be able to test message sending from Redis to your client (browser). Run `redis-cli` and publish a message to the channel you subscribed to in your Go server. By default, **goatee** expects your Redis messages to have a specified JSON format to send to the client with the following details: 59 | * `payload` 60 | * `created_at (optional)` 61 | 62 | E.x. `publish 'mychannel' '{"payload": "mymessage which is a string, etc."}'` 63 | 64 | ## Tests 65 | `go test github.com/johnernaut/goatee` 66 | 67 | ## Authors 68 | - [johnernaut](https://github.com/johnernaut) 69 | - you 70 | -------------------------------------------------------------------------------- /connection.go: -------------------------------------------------------------------------------- 1 | package goatee 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "time" 7 | 8 | "github.com/gorilla/websocket" 9 | ) 10 | 11 | const ( 12 | // Time allowed to write a message to the peer. 13 | writeWait = 10 * time.Second 14 | 15 | // Time allowed to read the next pong message from the peer. 16 | pongWait = 60 * time.Second 17 | 18 | // Send pings to peer with this period. Must be less than pongWait. 19 | pingPeriod = (pongWait * 9) / 10 20 | 21 | // Maximum message size allowed from peer. 22 | maxMessageSize = 1024 23 | ) 24 | 25 | type WSClient struct { 26 | Channel string `json:"channel"` 27 | Action string `json:"action"` 28 | Date string `json:"date"` 29 | Payload string `json:"payload"` 30 | Token string `json:"token"` 31 | } 32 | 33 | type connection struct { 34 | sid string 35 | ws *websocket.Conn 36 | send chan *Data 37 | client WSClient 38 | } 39 | 40 | func (c *connection) writer() { 41 | ticker := time.NewTicker(pingPeriod) 42 | 43 | defer func() { 44 | ticker.Stop() 45 | c.ws.Close() 46 | }() 47 | 48 | for { 49 | select { 50 | case message, ok := <-c.send: 51 | if !ok { 52 | c.ws.WriteMessage(websocket.CloseMessage, []byte{}) 53 | return 54 | } 55 | 56 | if c.client.Channel == message.Channel { 57 | err := c.ws.WriteJSON(message.Payload) 58 | if err != nil { 59 | log.Printf("Error in writer: %s", err.Error()) 60 | h.unregister <- c 61 | break 62 | } 63 | } 64 | case <-ticker.C: 65 | c.ws.SetWriteDeadline(time.Now().Add(writeWait)) 66 | if err := c.ws.WriteMessage(websocket.PingMessage, nil); err != nil { 67 | return 68 | } 69 | } 70 | } 71 | } 72 | 73 | func (c *connection) reader() { 74 | h.rclient.Lock() 75 | 76 | defer func() { 77 | h.unregister <- c 78 | h.rclient.Unlock() 79 | c.ws.Close() 80 | }() 81 | 82 | c.ws.SetReadLimit(maxMessageSize) 83 | c.ws.SetReadDeadline(time.Now().Add(pongWait)) 84 | c.ws.SetPongHandler(func(string) error { c.ws.SetReadDeadline(time.Now().Add(pongWait)); return nil }) 85 | 86 | for { 87 | var wclient WSClient 88 | err := c.ws.ReadJSON(&wclient) 89 | if err != nil { 90 | break 91 | } 92 | 93 | if DEBUG { 94 | log.Println("client type is:", wclient) 95 | } 96 | 97 | c.client = wclient 98 | 99 | switch wclient.Action { 100 | case "bind": 101 | h.rclient.Subscribe(wclient.Channel) 102 | case "unbind": 103 | h.rclient.Unsubscribe(wclient.Channel) 104 | case "message": 105 | d, err := json.Marshal(wclient) 106 | if err != nil { 107 | log.Println("Error marsahling json for publish: ", err) 108 | } 109 | 110 | _, err = h.rconn.Do("PUBLISH", wclient.Channel, d) 111 | if err != nil { 112 | log.Println("Error publishing message: ", err) 113 | } 114 | } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /notifier.go: -------------------------------------------------------------------------------- 1 | package goatee 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | 7 | "github.com/gomodule/redigo/redis" 8 | "github.com/gorilla/websocket" 9 | ) 10 | 11 | // type AuthFunc func(req *http.Request) bool 12 | 13 | type sockethub struct { 14 | // registered connections 15 | connections map[*connection]bool 16 | 17 | // inbound messages from connections 18 | broadcast chan *Data 19 | 20 | // register requests from connection 21 | register chan *connection 22 | 23 | // unregister request from connection 24 | unregister chan *connection 25 | 26 | // copy of the redis client 27 | rclient *RedisClient 28 | 29 | // copy of the redis connection 30 | rconn redis.Conn 31 | 32 | Auth func(req *http.Request) bool 33 | } 34 | 35 | var h = sockethub{ 36 | broadcast: make(chan *Data), 37 | register: make(chan *connection), 38 | unregister: make(chan *connection), 39 | connections: make(map[*connection]bool), 40 | } 41 | 42 | func (h *sockethub) WsHandler(w http.ResponseWriter, r *http.Request) { 43 | var authenticated bool 44 | 45 | if h.Auth != nil { 46 | authenticated = h.Auth(r) 47 | } 48 | 49 | if authenticated { 50 | ws, err := websocket.Upgrade(w, r, nil, 1024, 1024) 51 | 52 | if _, ok := err.(websocket.HandshakeError); ok { 53 | http.Error(w, "Not a websocket handshake", 400) 54 | return 55 | } else if err != nil { 56 | log.Printf("WsHandler error: %s", err.Error()) 57 | return 58 | } 59 | 60 | c := &connection{send: make(chan *Data), ws: ws} 61 | h.register <- c 62 | 63 | go c.writer() 64 | go c.reader() 65 | } else { 66 | http.Error(w, "Invalid API key", 401) 67 | } 68 | } 69 | 70 | func (h *sockethub) Run() { 71 | for { 72 | select { 73 | case c := <-h.register: 74 | h.connections[c] = true 75 | case c := <-h.unregister: 76 | if _, ok := h.connections[c]; ok { 77 | delete(h.connections, c) 78 | close(c.send) 79 | } 80 | case m := <-h.broadcast: 81 | for c := range h.connections { 82 | select { 83 | case c.send <- m: 84 | if DEBUG { 85 | log.Printf("broadcasting: %s", m.Payload) 86 | } 87 | default: 88 | close(c.send) 89 | delete(h.connections, c) 90 | } 91 | } 92 | } 93 | } 94 | } 95 | 96 | func (h *sockethub) RegisterAuthFunc(AuthFunc func(req *http.Request) bool) { 97 | aut := AuthFunc 98 | h.Auth = aut 99 | } 100 | 101 | func (h *sockethub) StartServer() { 102 | conf := LoadConfig("config") 103 | client, err := NewRedisClient(conf.Redis.Host) 104 | if err != nil { 105 | log.Fatal(err) 106 | } 107 | 108 | defer client.Close() 109 | 110 | go h.Run() 111 | 112 | http.HandleFunc("/", h.WsHandler) 113 | log.Println("Starting server on: ", conf.Web.Host) 114 | 115 | err = http.ListenAndServe(conf.Web.Host, nil) 116 | if err != nil { 117 | log.Fatal(err) 118 | } 119 | } 120 | 121 | func CreateServer() sockethub { 122 | return h 123 | } 124 | -------------------------------------------------------------------------------- /example/js/goatee.js: -------------------------------------------------------------------------------- 1 | /*! goatee.js 02-11-2014 */ 2 | (function(){"use strict";function a(c,d){b(c);var e=this;this.channel=null,this.event_emitter=new a.CommandDispatcher,this.connection=new a.ConnectionManager(d),this.connection.bind("connected",function(){e.connected=!0,console.log("Connected to goatee server.")}),this.connection.bind("message",function(a){console.log(e.channel),e.event_emitter.emit(e.channel,a)}),this.connection.bind("error",function(a){console.warn("Error connecting to goatee server: "+a)}),this.connection.bind("closed",function(){console.warn("Connection to the goatee server was closed.")}),this.connect(c)}function b(a){(null===a||void 0===a)&&console.warn("An API key must be supplied.")}var c=a.prototype;a.connected=!1,c.bind=function(a,b){return this.channel=a,this.connection.subscribe(a),this.event_emitter.bind(a,b),this},c.emit=function(a,b){return this.connection.send_event(a,b),this},c.connect=function(a){this.connection.connect(a)},this.goatee=a}).call(this),function(){"use strict";goatee.Utils={extend:function(a){for(var b=1;b0)for(c=0;c