DEV Community

Cover image for Simple Real-Time Chat App with Secutio, WebSockets & Go!
Henrique Dias
Henrique Dias

Posted on • Edited on

Simple Real-Time Chat App with Secutio, WebSockets & Go!

Secutio is a relatively new framework designed to streamline the development process for web applications, particularly single-page applications (SPAs). Its goal is to make building SPAs less complex.

Here's a breakdown of some key points about Secutio:

  • Simplifies Development: Secutio aims to reduce the inherent complexities involved in SPA development.
  • Task-Based Approach: Similar to how forms trigger actions, Secutio allows you to associate actions (called tasks) with any HTML element within your application.
  • Inspired by htmx: If you're familiar with htmx, a framework for building web applications with HTML, CSS, and Javascript, you might find some similarities with Secutio's approach. However, Secutio offers a distinct way of handling tasks.

In this previous post some fully functional demos can be seen.

The Project

This small project is intended to showcase the functionalities of the Secutio framework.

Let's create a simple chat web app using the "Secutio" framework to build the chat client and an extension to handle communication via Websockets. Below, I'll demonstrate how to load and register the extension. The server will be implemented in Go, and we'll utilize the standard "net/websocket" library.

Animated gif with the chat demo.

The project structure outlined below is provided as an example and can be tailored to suit the specific requirements of individual projects.

├── chat/
│   ├── public/
│   │   ├── css/
│   │   │   ├── styles.css
│   │   ├── index.html
│   │   ├── tasks.json
└── serve.go
Enter fullscreen mode Exit fullscreen mode

Chat Client

Now, we'll create the index.html file. In the <head> element, we will add a link to the stylesheet. Additionally, we'll define the path to the JSON file containing the tasks using the <script> element. We'll include the attribute data-tasktable and the src attribute with the file name. Tasks can also be added inline, operating similarly to styles.

<head>
    <title>Test Websockets</title>
    <link rel="stylesheet" href="css/styles.css">
    <script data-tasktable type="application/json" src="tasks.json"></script>
</head>
Enter fullscreen mode Exit fullscreen mode

We added the <main> element to contain the chat thread, a <textarea>, as well as the "send sentence" and "exit chat" <buttons>. Tasks are executed using the data-tasks attribute, which includes the name of the associated task in the "tasks.json" file.

<main id="main" data-tasks="show-login">
    <div class="chat">
        <input type="hidden" id="myself" name="myself" value="">
        <div id="chat-thread" data-tasks="get-chat-thread"></div>
        <div class="panel">
            <textarea id="sentence"></textarea>
            <div class="actions">
                <button id="send-sentence">
                    <span class="material-symbols-outlined">chat</span>
                </button>
                <button id="logout" data-tasks="logout">
                    <span class="material-symbols-outlined">logout</span>
                </button>
            </div>
        </div>
    </div>
</main>
Enter fullscreen mode Exit fullscreen mode

The task file contains properties associated with each task. Examining the first task "show-login", we observe that the trigger corresponds to the "init" event. This indicates that the task executes without user interaction, meaning it runs as soon as the page is fully loaded. It retrieves the template with the id "login-dialog-tpl" and appends it to the element target with the id "main".

The other tasks adhere to a similar pattern and can be executed either on the client-side or server-side, depending on the existence of the action and method properties. In this example, the approach follows the REST architecture. For instance, in the "get-chat-thread" task, an extension is defined, initialized, and registered after including the "secutio.js" and extension libraries.

tasks.json

{
    "show-login": {
        "trigger": "init",
        "target": "#main",
        "template": "#login-dialog-tpl",
        "swap": "append"
    },
    "login": {
        "action": "login",
        "method": "post",
        "callback": "login",
        "trigger": "click",
        "target": "#login-dialog > div",
        "template": "#login-dialog-message-tpl",
        "swap": "prepend",
        "then": "rm-dialog-message"
    },
    "rm-dialog-message": {
        "selector": "div.warning",
        "remove": {}
    },
    "logout": {
        "action": "logout",
        "attribute-action": "data-action",
        "method": "delete",
        "trigger": "click",
        "target": "#main",
        "template": "#login-dialog-tpl",
        "swap": "append",
        "then": "rm-dialog"
    },
    "rm-dialog": {
        "selector": "#login-dialog",
        "remove": {}
    },
    "get-chat-thread": {
        "extension": {
            "name": "websocket",
            "connect": "ws://localhost:8080/echo",
            "callback": "send-sentence",
            "element": "#send-sentence",
            "trigger": "click"
        },
        "trigger": "init",
        "target": "#chat-thread",
        "template": "#chat-thread-tpl",
        "swap": "prepend"
    }
}
Enter fullscreen mode Exit fullscreen mode

Templates

Secutio offers two ways to define template content within tasks:

  1. Inline definition: Use the HTML <script> with type "text/template" element with the character "#" at the beginning of the template name in the task definition. This is ideal for smaller or simpler templates.
  2. Loading from a file: Otherwise, the template name in the task definition is used to specify the template file path on server side. This approach is suitable for larger or more complex templates.

These templates operate similarly to server-side templating engines like PHP, but in this case, they leverage JavaScript's built-in "Template literals" for client-side rendering. This approach offers advantages like improved performance and a more dynamic user interface. Notably, the Secutio library can work with both server-side and client-side templating solutions depending on the project's requirements.

The first template is invoked by the "show-login" task, presenting a dialog for the user to input a name. Upon clicking the "login" <button>, the "login" task is executed. Subsequently, the second template is utilized to either close the dialog or display an error message if applicable.

The second template introduces the "task" object. This object holds properties like "ok" and "status" that provide information about the request's outcome.

<script id="login-dialog-tpl" type="text/template">
    <dialog id="login-dialog">
        <div>
            <label for="new-user">Choose your favorite name</label>
            <input type="text" id="new-user" name="new-user" value="">
            <button class="close-dialog" data-tasks="login">Login</button>
        </div>
    </dialog>
    <script>
        document.getElementById("login-dialog").showModal();
    </script>
</script>

<script id="login-dialog-message-tpl" type="text/template">
    ${(() => {
        if (task.ok) {
            if (task.status === 201) { // Created
                document.querySelector('#sentence').value = "Entered the room...";
                document.querySelector('#send-sentence').click();
                document.getElementById("login-dialog").close();
            }
            return "";
        }
        if (task.status === 302) { // Found
            const user = document.querySelector('#myself').value;
            document.querySelector('#myself').value = '';
            return `<div class="warning">The user ${user} already exist!</div>`;
        }
        return "";
    })()}
</script>
Enter fullscreen mode Exit fullscreen mode

Additionally, we have the template responsible for displaying each line of the chat. This template utilizes the variable "data" ("task.result" shortcode), which holds the information returned from the server, including the name of the user associated with the chat text.

<script id="chat-thread-tpl" type="text/template">
    <div class="chat-line">${(() => {
        const record = JSON.parse(data);
        const mySelf = document.getElementById("myself").value;
        return `<span class="user${(mySelf === record['login']) ? ' self' : ' '}">${record['login']}</span>
            <span>${record['data']}</span>`;
    })()}</div>
</script>
Enter fullscreen mode Exit fullscreen mode

Finally, we reach the section where the "secutio" library is added to the page and initialized. Since we intend to utilize an extension, it must be specified next.

<script type="text/javascript"
    src="https://cdn.jsdelivr.net/gh/mrhdias/secutio@master/dist/js/secutio.min.js"></script>
<script type="text/javascript"
    src="https://cdn.jsdelivr.net/gh/mrhdias/secutio@master/dist/js/ext/websocket.min.js"></script>
Enter fullscreen mode Exit fullscreen mode

Following initialization, the next step involves registering the extension, along with any additional callbacks required for processing supplementary data. All callbacks are linked to specific tasks, which are detailed in the "tasks.json" file above.

<script type="text/javascript">
    const app = new Secutio();
    app.init();

    app.extension_register('websocket', wsExtension);

    app.callback_register('login', ((task) => {
        const newUserElem = document.getElementById("new-user");
        if (newUserElem.value.length > 2) {
            task.data = { 'user': newUserElem.value };
            document.getElementById("myself").value = newUserElem.value;
            document.getElementById("logout").setAttribute('data-action', `logout/${newUserElem.value}`);
        }
    }));

    app.callback_register('send-sentence', ((socket) => {
        const userElem = document.querySelector('#myself');
        const dataElem = document.querySelector('#sentence');
        if (dataElem !== null && dataElem.value !== '') {
            socket.send(JSON.stringify({
                'login': userElem.value,
                'data': dataElem.value
            }));
            dataElem.value = '';
        }
    }));
</script>
Enter fullscreen mode Exit fullscreen mode

Chat Server

The server consists of two main parts:

  1. User Management: This part manages incoming and outgoing users. Client/server communication for user management also occurs via JSON. Here, JavaScript objects are likely encoded into JSON for data exchange during user creation (POST requests) and removal (DELETE requests). Responses adhere to typical HTTP statuses associated with the REST architecture (404 Not Found, 302 Found, 201 Created, etc.).
  2. Conversation Flow: This part orchestrates the flow of conversation between users. Similar to user management, communication here also utilizes JSON. The standard "net/websocket" library of the Go language is used for real-time communication. Messages can be encoded and exchanged in JSON format between clients and the server. JSON, being a lightweight and human-readable data format, is a popular choice for exchanging data over Websockets. However, it's important to note that Websockets themselves are a protocol for establishing a persistent connection, while JSON is a separate format for structuring data. You can use any data format with Websockets, not just JSON.

server.go

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "math"
    "net/http"
    "os/exec"
    "runtime"
    "strings"
    "time"

    "golang.org/x/net/websocket"
)

type User struct {
    Login string `json:"login"`
    Data  string `json:"data"`
}

type UserCheck struct {
    Exist bool   `json:"exist"`
    User  string `json:"user"`
}

type Connection struct {
    Ws             *websocket.Conn
    TimeInactivity *time.Time
}

type App struct {
    Connections map[string]*Connection
    Timeout     int
    PublicDir   string
    ListenPort  int
}

func openUrlInBrowser(url string) error {
    var cmd string
    var args []string

    switch runtime.GOOS {
    case "windows":
        cmd = "cmd"
        args = []string{"/c", "start"}
    case "darwin":
        cmd = "open"
    default: // "linux", "freebsd", "openbsd", "netbsd"
        cmd = "xdg-open"
    }
    args = append(args, url)

    return exec.Command(cmd, args...).Start()
}

func (app *App) checkInactivity() {
    for {
        usersLogout := []string{}
        for user := range app.Connections {
            difference := time.Until(*app.Connections[user].TimeInactivity)
            if math.Abs(difference.Minutes()) > float64(app.Timeout) {
                log.Printf("User Time Inactivity: %s (%.f)\n",
                    user, math.Abs(difference.Minutes()))
                delete(app.Connections, user)
                usersLogout = append(usersLogout, user)
            }
        }

        for _, userLogout := range usersLogout {
            userData := User{
                Login: userLogout,
                Data:  "Left the room",
            }
            jsonResp, err := json.Marshal(userData)
            if err != nil {
                log.Fatalln(err)
            }

            for user, connection := range app.Connections {
                if err := websocket.Message.Send(connection.Ws, string(jsonResp)); err != nil {
                    log.Printf("Can't send data to %s\r\n", user)
                    delete(app.Connections, user)
                }
            }
        }

        time.Sleep(time.Second * 5)
    }
}

func (app *App) wsHandler(ws *websocket.Conn) {

    go app.checkInactivity()

    for {
        var reply []byte
        if err := websocket.Message.Receive(ws, &reply); err != nil {
            log.Println("Can't receive")
            return
        }

        log.Printf("Received: %s: %s\n", ws.RemoteAddr(), string(reply))

        var user User

        if err := json.Unmarshal(reply, &user); err != nil {
            log.Fatalln(err)
        }

        if _, ok := app.Connections[user.Login]; !ok {
            log.Printf("user %s don't exist\n", user.Login)
            continue
        }
        if app.Connections[user.Login].Ws == nil {
            app.Connections[user.Login].Ws = ws
        }

        now := time.Now()
        app.Connections[user.Login].TimeInactivity = &now

        for login, connection := range app.Connections {
            if err := websocket.Message.Send(connection.Ws, string(reply)); err != nil {
                log.Printf("Can't send data to %s, so it is removed\n", login)
                delete(app.Connections, login)
            }
        }
    }
}

func (app *App) logout(w http.ResponseWriter, r *http.Request) {

    if r.Method != http.MethodDelete {
        http.Error(w, "Method not allowed",
            http.StatusMethodNotAllowed)
        return
    }

    path := r.URL.Path
    parts := strings.Split(path[1:], "/")
    if len(parts) != 2 {
        http.Error(w, "Bad Request",
            http.StatusBadRequest)
        return
    }

    user := parts[1]

    if _, ok := app.Connections[user]; !ok {
        http.Error(w, "Not Found",
            http.StatusNotFound)
        return
    }

    now := time.Now().Add(time.Minute * time.Duration(app.Timeout) * -1)

    app.Connections[user].TimeInactivity = &now

    w.WriteHeader(http.StatusOK)
}

func (app *App) login(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed",
            http.StatusMethodNotAllowed)
        return
    }

    var data map[string]string

    if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
        http.Error(w, "Bat Request",
            http.StatusBadRequest)
        return
    }

    if func() bool {
        if _, ok := app.Connections[data["user"]]; ok {
            return true
        }

        connection := Connection{}
        app.Connections[data["user"]] = &connection

        now := time.Now()
        app.Connections[data["user"]].TimeInactivity = &now

        return false
    }() {
        http.Error(w, "Found",
            http.StatusFound)
        return
    }

    w.WriteHeader(http.StatusCreated)
}

func NewApp() App {
    app := new(App)

    app.Connections = map[string]*Connection{}
    app.PublicDir = "./public"
    app.ListenPort = 8080
    app.Timeout = 15

    return *app
}

func main() {

    app := NewApp()

    mux := http.NewServeMux()

    mux.Handle("/echo", websocket.Handler(app.wsHandler))
    mux.HandleFunc("/login", app.login)
    mux.HandleFunc("/logout/", app.logout)
    mux.Handle("/", http.FileServer(http.Dir(app.PublicDir)))

    go func() {
        <-time.After(100 * time.Millisecond)
        openUrlInBrowser(fmt.Sprintf("%s:%d", "http://localhost", app.ListenPort))
    }()

    server := http.Server{
        Addr:    fmt.Sprintf(":%d", app.ListenPort),
        Handler: mux,
    }

    fmt.Printf("Server listening on %s\r\n", server.Addr)
    if err := server.ListenAndServe(); err != nil {
        panic(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Naturally, everything can be executed in the style adopted by htmx if you opt for that approach. The HTML content will then be dynamically generated on the server-side and placed directly into the designated target according to the task executed.

Now that all the server-side code is ready and saved in the root directory structure as described above, let's proceed to run the server using the following command:

$ go run .
Server listening on :8080
Enter fullscreen mode Exit fullscreen mode

Due to the "openUrlInBrowser" function, the web page will automatically open in your default browser. If it doesn't work, you can manually enter the address http://localhost:8080 to access it. Once on the page, you should see a dialog with the message "Choose your favorite name"

Conclusion

In this project, an extension was utilized for the first time to showcase the potential of extending the framework's functionalities. As noted earlier, the project is still in its early stages and subject to structural changes until a stable and reliable interface is achieved that meets users' needs, while maintaining the primary objective of simplicity.


The source code for this project is available here.

Additional details and explanations are available in the framework documentation. You can also access the code here.

Thank you for reading!

Top comments (0)