Creating a basic chat room

I remember when AOL instant messenger (AIM) was extremely popular. This page is dedicated to developing a simple chat room. It is an instructive exercise in working with WebSockets and channels in Go. A very rudimentary chatroom, reminiscent of what I saw in the 90's, can be developed in roughly 100 lines of Go code and some basic HTML and JavaScript front-end.



  • This package makes use of common modules from the standard library, as well as a package websocket maintained by the Gorilla web toolkit.
  • The instance websocket.Upgrader is used to upgrade an HTTP connection to a websocket connection, allowing for full-duplex communication between this server and any clients connected to it.
  • Communication over HTTP involves a client making a request and a server serving a response, over and over again for as long as necessary, each time establishing a new TCP connection (unless a Keep-Alive header specifies otherwise).
  • Websockets allow for multiple requests and responses to be sent over a continuously maintained TCP connection.
  • A Client struct is defined that contains a pointer to a websocket connection and a channel to send outgoing messages to any number of clients.
  • A Hub struct is defined to manage multiple clients.
  • The Hub contains a a map of clients. At first I used a slice of clients, but when I was working on the code shown below I realized that a map would provide for more efficient lookups used to register and unregister clients, and also avoid duplicate clients from being registered.
  • The Hub also contains a channel used to broadcast messages to all clients, channels for registering and unregistering clients, and a slice of byte slices, where each nested byte slice corresponds to a message that was sent, this supports newly registered clients from viewing the chat room history.
package main

import (
    "fmt"
    "log"
    "net/http"

    "github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
    ReadBufferSize:  1024,
    WriteBufferSize: 1024,
}

type Client struct {
    conn *websocket.Conn
    send chan []byte
}

type Hub struct {
    clients    map[*Client]bool
    broadcast  chan []byte
    register   chan *Client
    unregister chan *Client
    history    [][]byte
}

func newHub() *Hub {
    return &Hub{
        broadcast:  make(chan []byte),
        register:   make(chan *Client),
        unregister: make(chan *Client),
        clients:    make(map[*Client]bool),
        history:    make([][]byte, 0),
    }
}                         
                        




  • The run function uses a case statement to determine appropriate logic for the Hub.
  • It continuously listens for events on three channels and depending upon the event and channel will do one of the following:
  • When a new client is created it will send a reference to itself on the register channel. The run() function will then send existing chat history to this client and add it to the map of clients in the Hub.
  • When a client disconnects, a reference to it is sent on the unregister channel which will result in its deletion from the map of clients and closes its send channel.
  • When a byte slice containing message data is sent to the Hub on the broadcast channel that message is added to the chat room history and sent to all clients over each client's send channel.
  • If the client's send channel buffer is full (this means 256 messages have not been received, see below) it is assumed something has gone wrong and the client's send channel will be closed and the client removed from the Hub's map.
func (h *Hub) run() {
    for {
        select {
        case client := <-h.register:
            for _, message := range h.history {
                client.send <- message
            }
            h.clients[client] = true
        case client := <-h.unregister:
            if _, ok := h.clients[client]; ok {
                delete(h.clients, client)
                close(client.send)
            }
        case message := <-h.broadcast:
            h.history = append(h.history, message)
            for client := range h.clients {
                select {
                case client.send <- message:
                default:
                    close(client.send)
                    delete(h.clients, client)
                }
            }
        }
    }
}
                    




  • The read() function is used to read messages originating from clients and send them to the Hub> on the broadcast channel.
  • A closure is defined that will unregister the client and close its connection if an error occurs when the websocket attempts ro read a client's message server-side.
  • The write() function is used to send messages from the broadcast channel to connected clients. If an error occurs the connection is closed.
  • The serveWS function is a handler function, that when invoked by an endpoint being contacted by a client will upgrade that client's HTTP connection to a WebSocket.
  • This function then creates an instance of a Client using the newly upgraded connection and by creating a channel capable of containing 256 messages represented as byte slices.
func (c *Client) read(hub *Hub) {
    defer func() {
        hub.unregister <- c
        c.conn.Close()
    }()
    for {
        _, message, err := c.conn.ReadMessage()
        if err != nil {
            log.Println("error on read:", err)
            break
        }
        hub.broadcast <- message
    }
}

func (c *Client) write() {
    defer c.conn.Close()
    for message := range c.send {
        if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
            return
        }
    }
}

func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
    conn, err := upgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println("error on upgrade:", err)
        return
    }
    client := &Client{conn: conn, send: make(chan []byte, 256)}
    hub.register <- client

    go client.write()
    client.read(hub)
}
                        

Putting it all together



  • The main() function is an entry point for this Go program.
  • This function creates a new Hub and invokes the run() function associated with it.
  • A handler function is used to map the /ws endpoint of the server to the serveWs() function described above.
  • The root endpoint is used to display the existing messages.
  • The HTML code exposes a text input allowing clients to create new messages.
  • The JavaScript establishes a new WebSocket connection with the /ws endpoint on the server.
  • As messages are received via the WebSocket they are appended to a div used to display messages.
  • The sendMessage() function sends messages from the client's front-end to the WebSocket provided the message is not empty.
  • Lastly I used an event listener to send a message when the visitor presses the enter key.
func main() {
    hub := newHub()
    go hub.run()

    http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
        serveWs(hub, w, r)
    })

    http.Handle("/", http.FileServer(http.Dir("./public")))

    fmt.Println("Starting server at https://localhost:8443")
    err := http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", nil)
    if err != nil {
        fmt.Printf("Failed to start server: %v\n", err)
    }
} 
                        
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>WebSocket Chat</title>
    </head>
    <body>
        <h1>WebSocket Chat</h1>
        <div id="chat"></div>
        <input type="text" id="messageInput" placeholder="Type a message...">
        <button onclick="sendMessage()">Send</button>
    
        <script>
            let ws = new WebSocket("wss://localhost:8443/ws");
            let chat = document.getElementById("chat");
            let input = document.getElementById("messageInput");
    
            ws.onmessage = function(event) {
                let message = document.createElement("div");
                message.textContent = event.data;
                chat.appendChild(message);
            };
    
            function sendMessage() {
                if (input.value.trim() !== "") {
                    ws.send(input.value);
                    input.value = "";
                }
            }
    
            input.addEventListener("keyup", function(event) {
                if (event.key === "Enter") {
                    sendMessage();
                }
            });
        </script>
    </body>
    </html>

Multiple clients

I've used multiple browsers to simulate multiple clients and test that the chat room is able to display historical messages upon new client connections.



untrusted certificate warning



I learned a lot from this project but there are a number of improvements I would like to make:



  • Supporting account creation with a username, email, and password
  • Associating messages with usernames
  • Sending registered accounts an email digest of daily messages
  • Considerable improvements to the front-end UI: I have been looking for a reason to explore React.js which is a JavaScript library used to create user interfaces using reusable components. I would like to add styling to chat messages and the page in general.
  • Researching and implementing security best practices

To be continued...



View the project source code on GitHub

Top Of Page