Skip to main content

Go Websockets

Jomsom, Nepal

Project Setup

This project are my notes following along a tutorial by @tsawler and can be found on Github.

See Github Repository

Dependencies

Initialize the project:

mod init go_gorilla_websocket
go get github.com/CloudyKit/jet/v6   
go get: added github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53
go get: added github.com/CloudyKit/jet/v6 v6.1.0
go get github.com/bmizerany/pat   
go get: added github.com/bmizerany/pat v0.0.0-20210406213842-e4b6760bdd6f
go get github.com/gorilla/websocket
go get: added github.com/gorilla/websocket v1.4.2

Working with Jet

Jet is a templating engine for Go - just like EJS or Handlebar for Node.js - start by creating a simple home page with the .jet extension.

Create a HTML Page

html\home.jet

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content= "width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Home</title>
</head>
<body>
<h1>Frontpage</h1>
</body>

Create a Route

cmd\web\routes.go

package main

import (
"go_gorilla_websocket/internal/handlers"
"net/http"
"github.com/bmizerany/pat"
)

func routes() http.Handler {
m := pat.New()

m.Get("/", http.HandlerFunc(handlers.Home))

return m
}

Create a Handler

internal\handlers\handlers.go

package handlers

import (
"log"
"net/http"
"github.com/CloudyKit/jet/v6"
)

var views = jet.NewSet(
jet.NewOSFileSystemLoader("./html"),
jet.InDevelopmentMode(),
)

func Home(w http.ResponseWriter, r *http.Request) {
err := renderPage(w, "home.jet", nil)
if err != nil {
log.Println(err)
}
}

func renderPage(w http.ResponseWriter, tmpl string, data jet.VarMap) error {
view, err := views.GetTemplate(tmpl)
if err != nil {
log.Println(err)
return err
}

err = view.Execute(w, data, nil)
if err != nil {
log.Println(err)
return err
}

return nil
}

Create the Webserver

cmd\web\main.go

package main

import (
"log"
"net/http"
)

func main() {
m := routes()

log.Println("Starting Webserver on Port 8080")

_ = http.ListenAndServe(":8080", m)
}

You can start the app with:

go mod tidy
go run cmd/web/*.go
2021/09/26 20:31:26 Starting Webserver on Port 8080

Go to http://localhost:8080 to verify that the page is available.

Setting up a Websocket Connection

We can now add the Websocket Upgrade to our handlers:

internal\handlers\handlers.go

var upgradeConnection = websocket.Upgrader {
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {return true},
}

// Define the response returned from the websocket
type WsJsonResponse struct {
Action string `json: "action"`
Message string `json: "message"`
MessageType string `json: "message_type"`
ConnectedUsers []string `json:"connected_users"`
}

// Upgrade http connection to websocket
func WsEndpoint(w http.ResponseWriter, r *http.Request) {
ws, err := upgradeConnection.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
}

log.Println("Client Connected to Endpoint")

var response WsJsonResponse
response.Message = `<em><small>Connected to Server</small></em>`

err = ws.WriteJSON(response)
if err != nil {
log.Println(err)
}
}

And add a route to this WS Endpoint:

cmd\web\routes.go

package main

import (
"go_gorilla_websocket/internal/handlers"
"net/http"

"github.com/bmizerany/pat"
)

func routes() http.Handler {
m := pat.New()

m.Get("/", http.HandlerFunc(handlers.Home))
m.Get("/ws", http.HandlerFunc(handlers.WsEndpoint))

return m
}

And add a WS client script to our Home page:

html\home.jet

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content= "width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Home</title>
</head>
<body>
<h1>Frontpage</h1>
</body>
<script>
let socket = null;
// Wait for the page to be loaded then connect to the websocket
document.addEventListener("DOMContentLoaded", function() {
socket = new WebSocket("ws://127.0.0.1:8080/ws");
// Console log when successful
socket.onopen = () => {
console.log("Websocket connection established")
}
// Console log when closed
socket.onclose = () => {
console.log("Websocket connection closed")
}
// Console log errors
socket.onerror = error => {
console.log(error)
}
// Console log messages
socket.onmessage = msg => {
console.log(msg)
//Our message will be in `msg.data` and be in JSON
let jmsg = JSON.parse(msg.data)
console.log(jmsg)
}
})
</script>
</html>

Go Websockets

Restart the application and reload the Home page - you should now see a Websocket connection established inside your browser console as well as having a connection message on your terminal:

go run cmd/web/*.go
2021/09/26 21:00:28 Starting Webserver on Port 8080
2021/09/26 21:00:56 Client Connected to Endpoint

Using the Websocket Connection

Now I need to handle the messages that I want to be send through the connection. For this I first define the necessary types and variables for chat clients channels:

internal\handlers\handlers.go

var wsChn = make(chan WsPayload)
var clients = make(map[WebSocketConnection] string)

...

// Websocket type provided by the websocket package
type WebSocketConnection struct {
*websocket.Conn
}

// Define the response returned from the websocket
type WsPayload struct {
Action string `json: "action"`
Username string `json: "username"`
Message string `json: "message"`
Conn WebSocketConnection `json: "-"`
}

We need a function that listens to the ws connection and expects a payload of type WsPayload:

internal\handlers\handlers.go

func ListenForWs(conn *WebSocketConnection) {
//If listener crashes restart it
defer func() {
if r := recover(); r != nil {
log.Println("Error", fmt.Sprintf("%v", r))
}
}()

var payload WsPayload

// If there is payload send it to the ws Channel defined as a var of type `WsPayload`
for {
err := conn.ReadJSON(&payload)
if err != nil {
// do nothing
} else {
payload.Conn = *conn
wsChn <- payload
}
}
}

When the function above receives a payload it writes it to wsChn which we need to store in a variable e and forward it to a broadcast function that forwards it to all connected clients:

internal\handlers\handlers.go

func ListenToWsChannel() {
var response WsJsonResponse

for {
// forward the payload stored in `wsChn` to the broadcast function
e := <- wsChn
response.Action = "Got here"
response.Message = fmt.Sprintf("A message and action was %s", e.Action)
broadcastToAll(response)
}
}

func broadcastToAll(response WsJsonResponse) {
// Broadcast payload to all connected clients
for client := range clients {
err := client.WriteJSON(response)
// If you encounter an error delete the client
if err != nil {
log.Println("Websocket err")
_ = client.Close()
delete(clients, client)
}
}
}

We will trigger this routine by calling ListenForWs from the main WsEndpoint:

internal\handlers\handlers.go

func WsEndpoint(w http.ResponseWriter, r *http.Request) {
ws, err := upgradeConnection.Upgrade(w, r, nil)
if err != nil {
log.Println(err)
}

log.Println("Client Connected to Endpoint")

var response WsJsonResponse
response.Message = `<em><small>Connected to Server</small></em>`

conn := WebSocketConnection{Conn: ws}
clients[conn] = ""

err = ws.WriteJSON(response)
if err != nil {
log.Println(err)
}

go ListenForWs(&conn)
}

And adding it to our main function:

cmd\web\main.go

func main() {
m := routes()

log.Println("Starting channel listener")
go handlers.ListenToWsChannel()

log.Println("Starting Webserver on Port 8080")

_ = http.ListenAndServe(":8080", m)
}

Restart the server - you should now see the new message that the channel listener is active:

go run cmd/web/*.go
2021/09/27 12:21:33 Starting channel listener
2021/09/27 12:21:33 Starting Webserver on Port 8080

And also see the JSON formatted message in our browser console:

Go Websockets

Handling Connected Users

Our chat app has a input field where the user can type in a name he wants to use. We can now add a script to the Home html template that takes this name after the input field looses focus (user toggled from the username field to the message field) and writes it to a variable we can send to our backend:

html\home.jet

let userName = document.getElementById("username");
userName.addEventListener("change", function() {
let jsonData = {};
jsonData["action"] = "username";
jsonData["username"] = this.value;
socket.send(JSON.stringify(jsonData))
})

And the event is successfully fired - the message is send to our handler and the update was logged:

Go Websockets

But the result is not very useful yet - we first need to get rid of our placeholder content in ListenToWsChannel():

response.Action = "Got here"
response.Message = fmt.Sprintf("A message and action was %s", e.Action)

Instead I need to add each connected user to a list, sort it (because I can) and return it to the broadcast function:

internal\handlers\handlers.go

func ListenToWsChannel() {
var response WsJsonResponse

for {
// forward the payload stored in `wsChn` to the broadcast function
e := <- wsChn

// Do different things based on the action that triggered you
switch e.Action {
// If action is `username` send it to `getUserList` and broadcast the return
case "username":
// get a list of all users and send it to the broadcast function
clients[e.Conn] = e.Username
users := getUserList()
response.Action = "list_users"
response.ConnectedUsers = users
broadcastToAll(response)
// If action is `left` delete user from list that send the message
case "left":
response.Action = "list_users"
delete(clients, e.Conn)
users := getUserList()
response.ConnectedUsers = users
broadcastToAll(response)

}

// Placeholder
// response.Action = "Got here"
// response.Message = fmt.Sprintf("A message and action was %s", e.Action)
// broadcastToAll(response)
}
}


// Collect all connected user's names and return a sorted list
func getUserList() []string {

var userList []string

for _, x := range clients {
// If user name is not empty string append it
if x != "" {
userList = append(userList, x)
}
}
sort.Strings(userList)
return userList
}

Go Websockets

Ok, now we can handle this response from the backend by adding a script to our Home page:

html\home.jet

// send a message to server when user leaves
window.onbeforeunload = function() {
console.log("User disconnected")
let jsonData = {};
jsonData["action"] = "left";
socket.send(JSON.stringify(jsonData));
}

socket.onmessage = msg => {
// console.log(msg)
// Our message will be in `msg.data` and be in JSON
let data = JSON.parse(msg.data)
console.log("Action:", data.Action)

switch (data.Action) {
case "list_users":
// grab a unordered list by ID
let ul = document.getElementById("online_users");
// empty the list
while (ul.firstChild) ul.removeChild(ul.firstChild);
// if at least one user is connected
if (data.connected_users.length > 0) {
// loop through every user and create a list item for them
data.connected_users.forEach(function(item) {
let li = document.createElement("li")
li.classList.add("list-group-item");
li.appendChild(document.createTextNode(item))
ul.appendChild(li)
})
}
break;
}
}

We can use the list we are getting from our backend to render on our frontend by adding the unordered list with ID online users:

html\home.jet

<h3>Who is online?</h3>
<ul id="online_users">
</ul>

Go Websockets

Sending Messages

I now want to be able send data through the ws connection to my client and have it displayed on the web page. For this I first need to to create a place for it in the Home html template with ID output and a button with id sendBtn that takes the string from the message input with ID message and send it to our backend:

html\home.jet

<input type="text" name="message" id="message" />
<a href="javascript:void(0)" role="button" id="sendBtn">Send Message</a>
<input id="action" />
<div id="output">
</div>

When the send button is pressed take the username and message and send them through the websocket connection. To be able to identify it set label action to broadcast. Once send empty the message field:

html\home.jet

function sendMessage() {
let jsonData = {};
// Set `action` to be `broadcast`
jsonData["action"] = "broadcast";
// Take username and message
jsonData["username"] = document.getElementById("username").value;
jsonData["message"] = document.getElementById("message").value;
// and send them to the backend
socket.send(JSON.stringify(jsonData))
// Empty message field after message was send
document.getElementById("message").value = ""
}

I will add 2 ways of triggering the sendMessage function - the first one is by clicking ENTER on your keyboard:

html\home.jet

// When user types in a message and presses Enter send message
document.getElementById("message").addEventListener("keydown", function(event) {
if (event.code === "Enter") {
// First check if you are connected
if (!socket) {
console.log("You are not connected")
return false
}
// Prevent having browser overthink the event
event.preventDefault();
event.stopPropagation();
// Trigger send message function below
sendMessage();
}
})

In our handler file we now need a case for action broadcast that takes the username and message and sends them to all connected users:

internal\handlers\handlers.go

// If action is `broadcast` receive message and broadcast to all connected users
case "broadcast":
// Broadcast sends the username and a message
response.Action = "broadcast"
// Prepend Username in front of message
response.Message = fmt.Sprintf("<strong>%s</strong>: %s", e.Username, e.Message)
// And send to everyone
broadcastToAll(response)

}

On the client side we now have to take the broadcast and print it in our chat field with ID output:

html\home.jet

// Define the variable
let outPut = document.getElementById("output")

...

// Create the switch case for `broadcast`
case "broadcast":
// Take the message broadcast and output it into div with ID output
outPut.innerHTML = outPut.innerHTML + data.Message + "<br/>";
break;

To send the message via the send button we can first add a check that verifies that the message field is not empty before triggering the send function - if false sendMessage:

html\home.jet

let sendButton = document.getElementById("sendBtn")

...

sendButton.addEventListener("click", function() {
if ((userField.value === "") || (messageField.value === "")) {
alert ("Username and Message cannot be empty!");
return false;
} else {
sendMessage();
}
})
})

Go Websockets