├── .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 |
52 |
53 |
54 |
55 |
69 |
70 |
--------------------------------------------------------------------------------
/example/chan2.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | YERP
5 |
42 |
43 |
44 | Websocket Messages:
45 |
47 |
48 |
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 | [](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