├── .eslintrc.json ├── README.md ├── main.go └── static ├── index.html └── script.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6, 4 | "sourceType": "module", 5 | "ecmaFeatures": { 6 | "experimentalObjectRestSpread": true 7 | } 8 | }, 9 | "rules": { 10 | "semi": "error", 11 | "comma-dangle": "off", 12 | "no-console": "off", 13 | "keyword-spacing": "warn", 14 | "max-len": "warn", 15 | "key-spacing": "error", 16 | "prefer-const": "error", 17 | "space-before-function-paren": ["error", "never"], 18 | "space-before-blocks": ["warn", "always"], 19 | "arrow-spacing": "warn", 20 | "eqeqeq": ["error", "smart"], 21 | "no-constant-condition": "off", 22 | "curly": "error", 23 | "no-var": "warn", 24 | "object-shorthand": "warn" 25 | }, 26 | "extends": [ 27 | "eslint:recommended" 28 | ], 29 | "env": { 30 | "browser": true, 31 | "commonjs": true, 32 | "es6": true 33 | }, 34 | "globals": { 35 | "Scaledrone": true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Scaledrone Go Chat App Tutorial 2 | 3 | [Check out the tutorial.](https://www.scaledrone.com/blog/go-chat-app-tutorial-build-a-real-time-chat/) 4 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "net/http" 7 | "strconv" 8 | "time" 9 | 10 | jwt "github.com/dgrijalva/jwt-go" 11 | "github.com/gorilla/mux" 12 | ) 13 | 14 | const ( 15 | scaledroneID = "YOUR_SCALEDRONE_ID" // 👈 PS! Replace this with your own channel ID 🚨 16 | scaledroneSecret = "YOUR_SCALEDRONE_SECRET" // 👈 PS! Replace this with your own channel secret 🚨 17 | port = ":8080" 18 | ) 19 | 20 | func main() { 21 | r := mux.NewRouter() 22 | r.HandleFunc("/auth", auth).Methods("POST") 23 | r.PathPrefix("/").Handler(http.FileServer(http.Dir("./static"))).Methods("GET") 24 | fmt.Printf("Server is running on localhost%s", port) 25 | panic(http.ListenAndServe(port, r)) 26 | } 27 | 28 | type customClaims struct { 29 | jwt.StandardClaims 30 | Client string `json:"client"` 31 | Channel string `json:"channel"` 32 | Data userData `json:"data"` 33 | Permissions map[string]permissionClaims `json:"permissions"` 34 | } 35 | 36 | type permissionClaims struct { 37 | Publish bool `json:"publish"` 38 | Subscribe bool `json:"subscribe"` 39 | } 40 | 41 | type userData struct { 42 | Color string `json:"color"` 43 | Name string `json:"name"` 44 | } 45 | 46 | func getRandomName() string { 47 | adjs := []string{"autumn", "hidden", "bitter", "misty", "silent", "empty", "dry", "dark", "summer", "icy", "delicate", "quiet", "white", "cool", "spring", "winter", "patient", "twilight", "dawn", "crimson", "wispy", "weathered", "blue", "billowing", "broken", "cold", "damp", "falling", "frosty", "green", "long", "late", "lingering", "bold", "little", "morning", "muddy", "old", "red", "rough", "still", "small", "sparkling", "throbbing", "shy", "wandering", "withered", "wild", "black", "young", "holy", "solitary", "fragrant", "aged", "snowy", "proud", "floral", "restless", "divine", "polished", "ancient", "purple", "lively", "nameless"} 48 | nouns := []string{"waterfall", "river", "breeze", "moon", "rain", "wind", "sea", "morning", "snow", "lake", "sunset", "pine", "shadow", "leaf", "dawn", "glitter", "forest", "hill", "cloud", "meadow", "sun", "glade", "bird", "brook", "butterfly", "bush", "dew", "dust", "field", "fire", "flower", "firefly", "feather", "grass", "haze", "mountain", "night", "pond", "darkness", "snowflake", "silence", "sound", "sky", "shape", "surf", "thunder", "violet", "water", "wildflower", "wave", "water", "resonance", "sun", "wood", "dream", "cherry", "tree", "fog", "frost", "voice", "paper", "frog", "smoke", "star"} 49 | return adjs[rand.Intn(len(adjs))] + "_" + nouns[rand.Intn(len(nouns))] 50 | } 51 | 52 | func getRandomColor() string { 53 | return "#" + strconv.FormatInt(rand.Int63n(0xFFFFFF), 16) 54 | } 55 | 56 | func auth(w http.ResponseWriter, r *http.Request) { 57 | clientID := r.FormValue("clientID") 58 | if clientID == "" { 59 | http.Error(w, "No clientID defined", http.StatusUnprocessableEntity) 60 | return 61 | } 62 | 63 | // public room 64 | publicRoomRegex := "^observable-room$" 65 | // private room of the request user 66 | userPrivateRoomRegex := fmt.Sprintf("^private-room-%s$", clientID) 67 | // private rooms of every user besides the request user 68 | otherUsersPrivateRoomsRegex := fmt.Sprintf("^private-room-(?!%s$).+$", clientID) 69 | claims := customClaims{ 70 | StandardClaims: jwt.StandardClaims{ 71 | ExpiresAt: time.Now().Add(time.Minute * 3).Unix(), 72 | }, 73 | Client: clientID, 74 | Channel: scaledroneID, 75 | Data: userData{ 76 | Name: getRandomName(), 77 | Color: getRandomColor(), 78 | }, 79 | Permissions: map[string]permissionClaims{ 80 | publicRoomRegex: permissionClaims{ // public room 81 | Publish: true, // allow publishing to public chatroom 82 | Subscribe: true, // allow subscribing to public chatroom 83 | }, 84 | userPrivateRoomRegex: permissionClaims{ 85 | Publish: false, // no need to publish to ourselves 86 | Subscribe: true, // allow subscribing to private messages 87 | }, 88 | otherUsersPrivateRoomsRegex: permissionClaims{ 89 | Publish: true, // allow publishing to other users 90 | Subscribe: false, // don't allow subscribing to messages sent to other users 91 | }, 92 | }, 93 | } 94 | 95 | // Create a new token 96 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 97 | 98 | // Sign the token with our secret 99 | tokenString, err := token.SignedString([]byte(scaledroneSecret)) 100 | if err != nil { 101 | http.Error(w, "Unable to sign the token", http.StatusUnprocessableEntity) 102 | return 103 | } 104 | // Send the token to the user 105 | w.Write([]byte(tokenString)) 106 | } 107 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 86 | 87 | 88 |
89 |
Your name
90 |
91 |
Public rooms
92 |
93 |
Global public room
94 |
95 |
Connected users
96 |
97 |
98 |
99 |
Global public room
100 |
101 |
102 | 103 | 104 |
105 |
106 | 107 | 108 | 109 | -------------------------------------------------------------------------------- /static/script.js: -------------------------------------------------------------------------------- 1 | // 👇 PS! Replace this with your own channel ID 🚨 2 | const CLIENT_ID = 'YOUR_SCALEDRONE_ID'; 3 | 4 | // public room 5 | const PUBLIC_ROOM_NAME = 'observable-room'; 6 | // array of connected memebers 7 | let members = []; 8 | // the session user 9 | let me; 10 | // keeping track of which room the user has selected 11 | let selectedRoom = PUBLIC_ROOM_NAME; 12 | // room name to messages map, this is used to store messages for displaying them 13 | // at a later state 14 | const roomMessages = {}; 15 | 16 | const drone = new Scaledrone(CLIENT_ID); 17 | 18 | drone.on('open', error => { 19 | if (error) { 20 | return console.error(error); 21 | } 22 | // get JWT from the Go server for the clientID 23 | const formData = new FormData(); 24 | formData.append('clientID', drone.clientId); 25 | fetch('/auth', {body: formData, method: 'POST'}) 26 | .then(res => res.text()) 27 | .then(jwt => drone.authenticate(jwt)); 28 | }); 29 | 30 | drone.on('authenticate', error => { 31 | if (error) { 32 | return console.error(error); 33 | } 34 | console.log('Successfully connected to Scaledrone'); 35 | joinPublicRoom(); 36 | joinPersonalRoom(); 37 | }); 38 | 39 | // Start subscribing to messages from the public room 40 | function joinPublicRoom() { 41 | const publicRoom = drone.subscribe(PUBLIC_ROOM_NAME); 42 | publicRoom.on('open', error => { 43 | if (error) { 44 | return console.error(error); 45 | } 46 | console.log(`Successfully joined room ${PUBLIC_ROOM_NAME}`); 47 | }); 48 | 49 | // Received array of members currently connected to the public room 50 | publicRoom.on('members', m => { 51 | members = m; 52 | me = members.find(m => m.id === drone.clientId); 53 | DOM.updateMembers(); 54 | }); 55 | 56 | // New member joined the public room 57 | publicRoom.on('member_join', member => { 58 | members.push(member); 59 | DOM.updateMembers(); 60 | }); 61 | 62 | // Member left public room (closed browser tab) 63 | publicRoom.on('member_leave', ({id}) => { 64 | const index = members.findIndex(member => member.id === id); 65 | members.splice(index, 1); 66 | DOM.updateMembers(); 67 | }); 68 | 69 | // Received public message 70 | publicRoom.on('message', message => { 71 | const {data, member} = message; 72 | if (member && member !== me) { 73 | addMessageToRoomArray(PUBLIC_ROOM_NAME, member, data); 74 | if (selectedRoom === PUBLIC_ROOM_NAME) { 75 | DOM.addMessageToList(data, member); 76 | } 77 | } 78 | }); 79 | } 80 | 81 | // Start subscribing to messages from my private room (PMs to me) 82 | function joinPersonalRoom() { 83 | const roomName = createPrivateRoomName(drone.clientId); 84 | const myRoom = drone.subscribe(roomName); 85 | myRoom.on('open', error => { 86 | if (error) { 87 | return console.error(error); 88 | } 89 | console.log(`Successfully joined room ${roomName}`); 90 | }); 91 | 92 | myRoom.on('message', message => { 93 | const {data, clientId} = message; 94 | const member = members.find(m => m.id === clientId); 95 | if (member) { 96 | addMessageToRoomArray(createPrivateRoomName(member.id), member, data); 97 | if (selectedRoom === createPrivateRoomName(clientId)) { 98 | DOM.addMessageToList(data, member); 99 | } 100 | } else { 101 | /* Message is sent from golang using the REST API. 102 | * You can handle it like a regular message but it won't have a connection 103 | * session attached to it (this means no member argument) 104 | */ 105 | } 106 | }); 107 | } 108 | 109 | drone.on('close', event => { 110 | console.log('Connection was closed', event); 111 | }); 112 | 113 | drone.on('error', error => { 114 | console.error(error); 115 | }); 116 | 117 | function changeRoom(name, roomName) { 118 | selectedRoom = roomName; 119 | DOM.updateChatTitle(name); 120 | DOM.clearMessages(); 121 | if (roomMessages[roomName]) { 122 | roomMessages[roomName].forEach(({data, member}) => 123 | DOM.addMessageToList(data, member) 124 | ); 125 | } 126 | } 127 | 128 | function createPrivateRoomName(clientId) { 129 | return `private-room-${clientId}`; 130 | } 131 | 132 | function addMessageToRoomArray(roomName, member, data) { 133 | console.log('add', roomName, member.id, data); 134 | roomMessages[roomName] = roomMessages[roomName] || []; 135 | roomMessages[roomName].push({member, data}); 136 | } 137 | 138 | //------------- DOM Manipulation / Rendering the UI 139 | 140 | const DOM = { 141 | elements: { 142 | me: document.querySelector('.me'), 143 | membersList: document.querySelector('.members-list'), 144 | messages: document.querySelector('.messages'), 145 | input: document.querySelector('.message-form__input'), 146 | form: document.querySelector('.message-form'), 147 | chatTitle: document.querySelector('.chat-title'), 148 | room: document.querySelector('.room'), 149 | }, 150 | 151 | // Send message to Scaledrone and clear the input 152 | sendMessage() { 153 | const {input} = this.elements; 154 | const value = input.value; 155 | if (value === '') { 156 | return; 157 | } 158 | input.value = ''; 159 | drone.publish({ 160 | room: selectedRoom, 161 | message: value, 162 | }); 163 | addMessageToRoomArray(selectedRoom, me, value); 164 | this.addMessageToList(value, me); 165 | }, 166 | 167 | // Create DOM element with member name and color 168 | createMemberElement(member) { 169 | const { name, color } = member.authData; 170 | const el = document.createElement('div'); 171 | el.appendChild(document.createTextNode(name)); 172 | el.className = 'member'; 173 | el.style.color = color; 174 | if (member !== me) { 175 | // Listen to user clicking on another user 176 | el.addEventListener('click', () => 177 | changeRoom(member.authData.name, createPrivateRoomName(member.id)) 178 | ); 179 | } 180 | return el; 181 | }, 182 | 183 | // Rerender the list of connected members 184 | updateMembers() { 185 | this.elements.me.innerHTML = ''; 186 | this.elements.me.appendChild(this.createMemberElement(me)); 187 | this.elements.membersList.innerHTML = ''; 188 | members.filter(m => m !== me).forEach(member => 189 | this.elements.membersList.appendChild(this.createMemberElement(member)) 190 | ); 191 | }, 192 | 193 | // Create a DOM element for the message 194 | createMessageElement(text, member) { 195 | const el = document.createElement('div'); 196 | el.appendChild(this.createMemberElement(member)); 197 | el.appendChild(document.createTextNode(text)); 198 | el.className = 'message'; 199 | return el; 200 | }, 201 | 202 | // Add message element to the messages container 203 | addMessageToList(text, member) { 204 | const el = this.elements.messages; 205 | const wasTop = el.scrollTop === el.scrollHeight - el.clientHeight; 206 | el.appendChild(this.createMessageElement(text, member)); 207 | if (wasTop) { 208 | el.scrollTop = el.scrollHeight - el.clientHeight; 209 | } 210 | }, 211 | 212 | updateChatTitle(roomName) { 213 | this.elements.chatTitle.innerText = roomName; 214 | }, 215 | 216 | clearMessages() { 217 | this.elements.messages.innerHTML = ''; 218 | }, 219 | }; 220 | // Listen to submitting the input form 221 | DOM.elements.form.addEventListener('submit', () => 222 | DOM.sendMessage() 223 | ); 224 | // Listen to user clicking on the public room label 225 | DOM.elements.room.addEventListener('click', () => 226 | changeRoom('Public room', PUBLIC_ROOM_NAME) 227 | ); 228 | --------------------------------------------------------------------------------