DEV Community

arif
arif

Posted on

Creating Real-Time WebSockets with Go and WebAssembly

Writing WebAssembly with Go was always something I wanted to do, but finding examples or tutorials was hard, so I decided to shuffle through pages.

In this post, I'll share with you my learnings and some code examples.

This project is an exciting exploration into the world of real-time web communication. We're delving into connecting a WebSocket directly from WebAssembly (Wasm) code, enabling dynamic interactions within the web page. By leveraging WebSocket events, we can manipulate the Document Object Model (DOM) in response to live data, creating an interactive and responsive user experience.

Project Outline

In this project, we will create a WebSocket service (using Gorilla WebSocket), a WASM service, and an index.html (this is solely to see what we've done).

Folder Structure

In this basic project, we're keeping the folder structure straightforward to avoid complexity. All that's required is a wasm/ directory for the WebAssembly files, a pkg/socket/ directory for the socket service logic, and a public/ directory to house our frontend assets.

So project will look like this.

main.go
wasm/
pkg/socket/
public/
Makefile
Enter fullscreen mode Exit fullscreen mode

Let's Code!

public/index.html

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Go WASM Button Example</title>
  <script src="wasm_exec.js"></script>
  <style>
    #btn {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
    }
  </style>
</head>

<body>
  <p id="text"></p>
  <button id="btn">Click me</button>
  <script>
    const go = new Go();

    WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject).then((result) => {
      go.run(result.instance);
    });

    document.getElementById("btn").addEventListener("click", () => {
      onButtonClick();
    });
  </script>
</body>

</html>
Enter fullscreen mode Exit fullscreen mode

Our HTML layout is intentionally minimalistic, featuring a single button centrally placed on the screen. When clicked, this button activates a 'click' event, which in turn calls the buttonClick() function.

Additionally, positioned at the top of the screen is a <p> tag with id text, which we will dynamically manipulate from our WebAssembly code.

pkg/socket/manage.go

package socket

import (
    "log"
    "math/rand"
    "net/http"
    "sync"

    "github.com/gorilla/websocket"
)

// Pre-configure the upgrader, which is responsible for upgrading
// an HTTP connection to a WebSocket connection.
var (
    websocketUpgrader = websocket.Upgrader{
        ReadBufferSize:  1024,
        WriteBufferSize: 1024,
    }
)

// NotifyEvent represents an event that contains a reference
// to the client who initiated the event and the message to be notified.
type NotifyEvent struct {
    client  *Client
    message string
}

// Client represents a single WebSocket connection.
// It holds the client's ID, the WebSocket connection itself, and
// the manager that controls all clients.
type Client struct {
    id         uint32
    connection *websocket.Conn
    manager    *Manager

    writeChan chan string
}

// Manager keeps track of all active clients and broadcasts messages.
type Manager struct {
    clients ClientList

    sync.RWMutex

    notifyChan chan NotifyEvent
}

// ClientList is a map of clients to keep track of their presence.
type ClientList map[*Client]bool

// NewClient creates a new Client instance with a unique ID, its connection,
// and a reference to the Manager.
func NewClient(conn *websocket.Conn, manager *Manager) *Client {
    return &Client{
        id:         rand.Uint32(),
        connection: conn,
        manager:    manager,
        writeChan:  make(chan string),
    }
}

// readMessages continuously reads messages from the WebSocket connection.
// It will send any received messages to the manager's notification channel.
func (c *Client) readMessages() {
    defer func() {
        c.manager.removeClient(c)
    }()

    for {
        messageType, payload, err := c.connection.ReadMessage()

        c.manager.notifyChan <- NotifyEvent{client: c, message: string(payload)}

        if err != nil {
            if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
                log.Printf("error reading message: %v", err)
            }
            break
        }
        log.Println("MessageType: ", messageType)
        log.Println("Payload: ", string(payload))
    }
}

// writeMessages listens on the client's write channel for messages
// and writes any received messages to the WebSocket connection.
func (c *Client) writeMessages() {
    defer func() {
        c.manager.removeClient(c)
    }()

    for {
        select {
        case data := <-c.writeChan:
            c.connection.WriteMessage(websocket.TextMessage, []byte(data))
        }
    }
}

// NewManager creates a new Manager instance, initializes the client list,
// and starts the goroutine responsible for notifying other clients.
func NewManager() *Manager {
    m := &Manager{
        clients:    make(ClientList),
        notifyChan: make(chan NotifyEvent),
    }

    go m.notifyOtherClients()

    return m
}

// otherClients returns a slice of clients excluding the provided client.
func (m *Manager) otherClients(client *Client) []*Client {
    clientList := make([]*Client, 0)

    for c := range m.clients {
        if c.id != client.id {
            clientList = append(clientList, c)
        }
    }

    return clientList
}

// notifyOtherClients waits for notify events and broadcasts the message
// to all clients except the one who sent the message.
func (m *Manager) notifyOtherClients() {
    for {
        select {
        case e := <-m.notifyChan:
            otherClients := m.otherClients(e.client)

            for _, c := range otherClients {
                c.writeChan <- e.message
            }
        }
    }
}

// addClient adds a new client to the manager's client list.
func (m *Manager) addClient(client *Client) {
    m.Lock()
    defer m.Unlock()

    m.clients[client] = true
}

// removeClient removes a client from the manager's client list and
// closes the WebSocket connection.
func (m *Manager) removeClient(client *Client) {
    m.Lock()
    defer m.Unlock()

    if _, ok := m.clients[client]; ok {
        client.connection.Close()
        delete(m.clients, client)
    }
}

// ServeWS is an HTTP handler that upgrades the HTTP connection to a
// WebSocket connection and registers the new client with the manager.
func (m *Manager) ServeWS(w http.ResponseWriter, r *http.Request) {
    log.Println("New Connection")

    conn, err := websocketUpgrader.Upgrade(w, r, nil)
    if err != nil {
        log.Println(err)
        return
    }

    client := NewClient(conn, m)
    m.addClient(client)

    go client.readMessages()
    go client.writeMessages()
}
Enter fullscreen mode Exit fullscreen mode

Our WebSocket service is powered by a Go file that relies on the gorilla/websocket library. It begins with setting up an Upgrader from the library, which transitions an HTTP connection to the WebSocket protocol, allowing for real-time communication.

The code defines a NotifyEvent type that encapsulates messages from a client (an active WebSocket connection) and a Client type that keeps track of individual connections and their communication with the server through channels.

The Manager type oversees all Client instances, managing incoming and outgoing messages with the help of ClientList, a map that keeps track of all active connections.

Clients are created with a unique ID and are tied to their WebSocket connections and the managing system. They have two main routines: readMessages listens for incoming messages and forwards them to the manager, and writeMessages sends outgoing messages from the server to the client's WebSocket.

The Manager is also responsible for broadcasting messages to all clients except the sender, ensuring that messages are propagated in real-time to all connected users.

Lastly, ServeWS is a function that handles new WebSocket connections by upgrading HTTP requests to WebSocket and initiating the message reading and writing routines.

This structure allows us to manage multiple clients and their interactions efficiently, forming the backbone of our WebSocket-based real-time communication service.

main.go

main.go, in this file we need to serve our index.html and some websocket stuff. ill use net/http package for this.

package main

import (
    "log"
    "net/http"

    "github.com/doganarif/wasm/2/pkg/socket"
)

func main() {
    setupAPI()

    // Serve on port :8080, fudge yeah hardcoded port
    log.Fatal(http.ListenAndServe(":8080", nil))
}

// setupAPI will start all Routes and their Handlers
func setupAPI() {
    manager := socket.NewManager()

    // Serve the ./public directory at Route /
    http.Handle("/", http.FileServer(http.Dir("./public")))
    http.Handle("/ws", http.HandlerFunc(manager.ServeWS))
}
Enter fullscreen mode Exit fullscreen mode

Our web service has a simple starting point, a file where everything kicks off. It sets up our web server and tells it to listen for visitors on port 8080 (the classic spot for web development testing).

We call a setupAPI() function that does a bit of behind-the-scenes work. It taps into our socket package to create a Manager, which is like the director of a play, coordinating all our WebSocket connections.

We also set up a special /ws route for WebSocket connections, where our Manager starts handling the real-time chat.

wasm/wasm.go

package main

import (
    "context"
    "fmt"
    "log"
    "syscall/js"

    "nhooyr.io/websocket"
)

// Conn wraps a WebSocket connection.
type Conn struct {
    wsConn *websocket.Conn
}

// NewConn establishes a new WebSocket connection to a specified URL.
func NewConn() *Conn {
    c, _, err := websocket.Dial(context.Background(), "ws://localhost:8080/ws", nil)
    if err != nil {
        fmt.Println(err, "ERROR")
    }

    return &Conn{
        wsConn: c,
    }
}

func main() {
    // Channel to keep the main function running until it's closed.
    c := make(chan struct{}, 0)

    println("WASM Go Initialized")
    // Establish a new WebSocket connection.
    conn := NewConn()

    // Register the onButtonClick function in the global JavaScript context.
    js.Global().Set("onButtonClick", onButtonClickFunc(conn))

    // Start reading messages in a new goroutine.
    go conn.readMessage()

    // Wait indefinitely.
    <-c
}

// onButtonClickFunc returns a js.Func that sends a "HELLO" message over WebSocket when invoked.
func onButtonClickFunc(conn *Conn) js.Func {
    return js.FuncOf(func(this js.Value, args []js.Value) interface{} {
        println("Button Clicked!")
        // Send a message through the WebSocket connection.
        err := conn.wsConn.Write(context.Background(), websocket.MessageText, []byte("HELLO"))
        if err != nil {
            log.Println("Error writing to WebSocket:", err)
        }
        return nil
    })
}

// readMessage handles incoming WebSocket messages and updates the DOM accordingly.
func (c *Conn) readMessage() {
    defer func() {
        // Close the WebSocket connection when the function returns.
        c.wsConn.Close(websocket.StatusGoingAway, "BYE")
    }()

    for {
        // Read a message from the WebSocket connection.
        messageType, payload, err := c.wsConn.Read(context.Background())

        if err != nil {
            // Log and panic if there is an error reading the message.
            log.Panicf(err.Error())
        }

        // Update the DOM with the received message.
        updateDOMContent(string(payload))

        // Log the message type and payload for debugging.
        log.Println("MessageType: ", messageType)
        log.Println("Payload: ", string(payload))
    }
}

// updateDOMContent updates the text content of the DOM element with the given text.
func updateDOMContent(text string) {
    // Get the document object from the global JavaScript context.
    document := js.Global().Get("document")
    // Get the DOM element by its ID.
    element := document.Call("getElementById", "text")
    // Set the innerText of the element to the provided text.
    element.Set("innerText", text)
}
Enter fullscreen mode Exit fullscreen mode

Our wasm.go file, once compiled into a .wasm executable, is the client-side counterpart to the WebSocket service we discussed earlier. It's set to run within the web browser, interfacing directly with the DOM.

The file’s responsibility starts with establishing a WebSocket connection to the server endpoint we've set up—ws://localhost:8080/ws. This is the communication channel our web service listens to, we already detailed.

In parallel, we're attentive to the server’s response. Incoming messages from our WebSocket server are caught and used to update the webpage dynamically.
This is where the full circle of client-server interaction completes—our Go code receives server messages, then reflects changes directly in the user's view (<p id="text").

Makefile

.PHONY: build buildwasm run clean serve

# Variables
WASM_DIR := ./wasm
PUBLIC_DIR := ./public
SERVER_FILE := main.go
WASM_SOURCE := $(WASM_DIR)/wasm.go
WASM_TARGET := $(PUBLIC_DIR)/main.wasm
GOOS := js
GOARCH := wasm

# Default rule
all: run

# Run the server
run: serve

# Serve the project
serve: buildwasm
    go run $(SERVER_FILE)

# Build the WebAssembly module
buildwasm:
    GOOS=$(GOOS) GOARCH=$(GOARCH) go build -o $(WASM_TARGET) $(WASM_SOURCE)

# Clean the built artifacts
clean:
    rm -f $(WASM_TARGET)
Enter fullscreen mode Exit fullscreen mode

Conclusion

We've gone through the ropes of creating a real-time application using Go and WebAssembly. The project's setup is simple: a WebSocket server in Go, a basic HTML page, and some Go code compiled to WebAssembly that runs in the browser. We've covered how to send and receive messages through WebSockets and how to reflect those messages in the web page by updating the DOM.

From setting up the project structure to handling real-time communication between the client and server, the provided code examples aim to give you a clear starting point for building your own applications with Go and WebAssembly. It's a straightforward setup, but it lays the foundation for more complex projects.

Hope this helps you kickstart your own adventures in WebAssembly with Go. Keep building, keep learning, and most importantly, keep it simple.

Github Repo : https://github.com/doganarif/go-wasm-socket

Top comments (0)