DEV Community

Sarmad Abdullah
Sarmad Abdullah

Posted on

WebAssembly Made Easy: Building a Chat App

WebAssembly is a relatively new technology that allows building native apps for the web, which can provide extra performance, lower memory footprint, and improved security over traditional JS based web applications. In this tutorial we'll walk through building a simple WebAssembly based application. The app is a simple text based chat application. For this tutorial we'll use Alusus programming language, a new promising general purpose language which provides good support for WebAssembly.

Requirements

We'll keep the requirement simple for the sake of this tutorial. When the user launches the app he/she should be presented with a dialog where you enter a username, a user password, a room name, and a room password. Once the user enters those info, we'll log the user into that room. Like any other chat app, the room has an entry box where the user enters a new message, and an area for showing chat messages from all members of that room.

All you need in terms of development environment is to install Alusus language in any modern linux distro, and have any text editor.

Basic Chat Frontend

We will start off by building a basic UI. With Alusus and WebPlatform you build everything in Alusus itself without the need to embed any HTML or CSS; those will be generated for you by the compiler, so you build the app as if you are building a desktop application using some UI toolkit.

We will create two separate components, one for the header and one for the text entry, and we will have one main endpoint that displays the UI. Here is how the full code looks like:

import "Srl/String";
import "Apm";
Apm.importFile("Alusus/WebPlatform");
use Srl;
use WebPlatform;

//==============================================================================
// Frontend Components

class Header {
    @injection def component: Component;

    handler this~init() {
        this.view = Box({}).{
            style.{
                padding = Length4.pt(4);
                borderWidth = Length4.pt(0, 0, 1.5, 0);
                borderStyle = BorderStyle.SOLID;
                borderColor = Color("000");
                justify = Justify.START;
                display = Display.FLEX;
                layout = Layout.COLUMN;
                background = Background(Color("fff"));
            };
            addChildren({
                Text(String("Wasm Chat")).{
                    style.fontSize = Length.pt(18.0);
                }
            });
        };
    }

    handler this_type(): SrdRef[Header] {
        return SrdRef[Header].construct();
    }
}

class TextEntry {
    @injection def component: Component;

    handler this~init() {
        def self: ref[this_type](this);

        this.view = Box({}).{
            style.{
                display = Display.FLEX;
                layout = Layout.ROW;
                justify = Justify.SPACE_BETWEEN;
            };
            addChildren({
                TextInput().{
                    style.{
                        flex = Flex(1);
                        margin = Length4.px(10);
                        height = Length.px(50);
                        fontSize = Length.pt(12.0);
                        borderStyle = BorderStyle.SOLID;
                        borderColor = Color("000");
                        borderWidth = Length4.px(1.5);
                    };
                },
                Button(String("Send")).{
                    style.{
                        height = Length.px(50);
                        width = Length.px(50);
                        fontSize = Length.px(16.0);
                        justify = Justify.CENTER;
                        margin = Length4.px(10, 10, 10, 0);
                    };
                }
            });
        };
    }

    handler this_type(): SrdRef[TextEntry] {
        return SrdRef[TextEntry].construct();
    }
}

//==============================================================================
// Frontend Pages

@uiEndpoint["/"]
@title["Wasm Chat"]
func main {
    Window.instance.style.{
        padding = Length4.pt(0);
        margin = Length4.pt(0);
    };
    Window.instance.setView(Box({}).{
        style.{
            height = Length.percent(100);
            justify = Justify.SPACE_BETWEEN;
            display = Display.FLEX;
            layout = Layout.COLUMN;
            background = Background(Color("aaa"));
        };
        addChildren({
            Header(),
            Text(String()).{
                style.{
                    flex = Flex(1);
                    width = Length.percent(100) - Length.px(20);
                    margin = Length4.px(10, 10, 0, 10);
                    fontSize = Length.pt(20.0);
                    borderStyle = BorderStyle.SOLID;
                    borderColor = Color("000");
                    borderWidth = Length4.px(1.5);
                    background = Background(Color("fff"));
                };
            },
            TextEntry()
        })
    });

    runEventLoop();
}

//==============================================================================
// Entry Point

Console.print("Starting server on port 8000...\nURL: http://localhost:8000/\n");
buildAndRunServer(Array[CharsPtr]({ "listening_ports", "8000", "static_file_max_age", "0" }));
Enter fullscreen mode Exit fullscreen mode

The code itself is pretty self explanatory; the UI endpoint is a function marked with the @uiEndpoint modifier. In this function we are doing two things: setting the main view then entering the event loop. The view is a Box, which is equivalent to a div in HTML, and in that box there is the header, a text view for viewing chat history, and the text entry component. The Text and Header components are simply classes that inherit from WebPlatform's Component class and create the component's view in the constructor.
If you are familiar with CSS you will recognize the styles we are assigning to each of the widgets. Each of these styles is equivalent to its HTML counterpart, but are defined in a strongly typed manner, and hence you'll see the use of enumerations and classes instead of plain strings.

Put all that code in a single file called chat.alusus, then run it like this:

alusus chat.alusus
Enter fullscreen mode Exit fullscreen mode

Here is how the page looks like:

Chat Main UI

The UI currently does nothing as it's not linked to any backend yet, so let's get to that.

Adding a Backend

Let's keep it simple for the first step and have the backend store the chat data in memory. We'll build two endpoints, one for posting a new message, and one for fetching the message history. We will skip the rooms for now and keep all messages in one global publicly available pool of messages, so everyone will see all messages from everyone. The messages for now will simply be stored in a global array of strings.

It's important to keep in mind that Alusus and WebPlatform are multi-threaded, so global variables will need to be thread safe. So, we will use a mutex to guard the access to this global array. Our backed will look like this:

//==============================================================================
// Backend

def MAX_MESSAGES: 12;
def messages: Array[String];
def mutex: Threading.Mutex;
Threading.initMutex(mutex~ptr, 0);

@beEndpoint["POST", "/messages"]
func postMessages (conn: ptr[Http.Connection]) {
    def postData: array[Char, 1024];
    def postDataSize: Int = Http.read(conn, postData~ptr, postData~size);
    Threading.lockMutex(mutex~ptr);
    if messages.getLength() >= MAX_MESSAGES messages.remove(0);
    messages.add(String(postData~ptr, postDataSize));
    Threading.unlockMutex(mutex~ptr);
    Http.print(conn, "HTTP/1.1 200 Ok\r\n\r\n");
}

@beEndpoint["GET", "/messages"]
func getMessages (conn: ptr[Http.Connection]) {
    Threading.lockMutex(mutex~ptr);
    def response: String = String.merge(messages, "\n");
    Threading.unlockMutex(mutex~ptr);
    Http.print(conn, "HTTP/1.1 200 Ok\r\n");
    Http.print(conn, "Content-Type: text/plain\r\n");
    Http.print(conn, "Cache-Control: no-cache\r\n");
    Http.print(conn, "Content-Length: %d\r\n\r\n", response.getLength());
    Http.print(conn, response.buf);
}
Enter fullscreen mode Exit fullscreen mode

Each REST endpoint is a function marked with the @beEndpoint modifier, giving it the method and the route for that endpoint. You'll see that in each of these two endpoints we lock the mutex before accessing the global messages variable, then we unlock it afterwards.

You can put this backend code in a separate file and then import it from chat.alusus, and you can also keep inside chat.alusus. In Alusus, you do not have to separate the backend from the front end, nor do you need to run them separately; WebPlatform takes care of all of that for you.

Now that we have the backend, we need to hook the frontend to it. We have a text entry component that isn't listening to any events, so let's listen to the button click and key-up events and notify the owner:

class TextEntry {
    @injection def component: Component;

    def onNewEntry: closure (String);
    def textInput: SrdRef[TextInput];

    handler this~init() {
        def self: ref[this_type](this);

        this.view = Box({}).{
            style.{
                display = Display.FLEX;
                layout = Layout.ROW;
                justify = Justify.SPACE_BETWEEN;
            };
            addChildren({
                TextInput().{
                    self.textInput = this;
                    style.{
                        flex = Flex(1);
                        margin = Length4.px(10);
                        height = Length.px(50);
                        fontSize = Length.pt(12.0);
                        borderStyle = BorderStyle.SOLID;
                        borderColor = Color("000");
                        borderWidth = Length4.px(1.5);
                    };
                    onKeyUp.connect(closure (widget: ref[TextInput], payload: ref[String]) {
                        if payload == "Shift+Enter" {
                            def newData: String = String("- ") + widget.getText().trim();
                            widget.setText(String());
                            if not self.onNewEntry.isNull() self.onNewEntry(newData);
                        }
                    });
                },
                Button(String("Send")).{
                    style.{
                        height = Length.px(50);
                        width = Length.px(50);
                        fontSize = Length.px(16.0);
                        justify = Justify.CENTER;
                        margin = Length4.px(10, 10, 10, 0);
                    };
                    onClick.connect(closure (widget: ref[Widget], payload: ref[Int]) {
                        def newData: String = String("- ") + self.textInput.getText().trim();
                        self.textInput.setText(String());
                        if not self.onNewEntry.isNull() self.onNewEntry(newData);
                    });
                }
            });
        };
    }

    handler this_type(): SrdRef[TextEntry] {
        return SrdRef[TextEntry].construct();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now the owner of the component just needs to provide the onNewEntry callback. In the UI endpoint we will do two things:

  • Listen to new entries and send them over to the backend.
  • Periodically fetch the chat history from the backend and display it.
@uiEndpoint["/"]
@title["Wasm Chat"]
func main {
    def onFetch: closure (json: Json);

    Window.instance.style.{
        padding = Length4.pt(0);
        margin = Length4.pt(0);
    };
    Window.instance.setView(Box({}).{
        style.{
            height = Length.percent(100);
            justify = Justify.SPACE_BETWEEN;
            display = Display.FLEX;
            layout = Layout.COLUMN;
            background = Background(Color("aaa"));
        };
        addChildren({
            Header(),
            Text(String()).{
                style.{
                    flex = Flex(1);
                    width = Length.percent(100) - Length.px(20);
                    margin = Length4.px(10, 10, 0, 10);
                    fontSize = Length.pt(20.0);
                    borderStyle = BorderStyle.SOLID;
                    borderColor = Color("000");
                    borderWidth = Length4.px(1.5);
                    background = Background(Color("fff"));
                };
                onFetch = closure (json: Json) {
                    def status: Int[64] = json("eventData")("status");
                    if status >= 200 and status < 300 {
                        def data: String = json("eventData")("body");
                        if this.getText() != data {
                            this.setText(data);
                        }
                    }
                };
            },
            TextEntry().{
                onNewEntry = closure (newData: String) {
                    sendRequest(
                        "POST", "/messages", "Content-Type: application/text", newData, 10000,
                        closure (Json) {}
                    );
                    sendRequest("GET", "/messages", null, null, 500, onFetch);
                };
            };
        })
    });

    startTimer(500000, closure (json: Json) {
        sendRequest("GET", "/messages", null, null, 500, onFetch);
    });
    sendRequest("GET", "/messages", null, null, 500, onFetch);

    runEventLoop();
}
Enter fullscreen mode Exit fullscreen mode

The rest of the app looks the same, and running the app is done the same way as shown in the previous section.

Now we can have multiple people chat over this simple chat application, but the data is not persisted yet, so if we restart the server all data will be gone. Let's fix that.

Persisting the Data

We are still keeping all messages in a single pool, so we only need a single data model to store those messages. We will store the messages in a PostgreSQL database. After setting up Postgres you need to import the Rows ORM library, and this can be done by adding this line nexto the where we are importing WebPlatform:

Apm.importFile("Alusus/Rows", { "Rows.alusus", "Drivers/Postgresql.alusus" });
Enter fullscreen mode Exit fullscreen mode

Notice that we are telling Alusus Package Manager to import the Postgresql driver in addition to the Rows library itself. This driver is part of the Rows library but is not imported by default in case the user wants to use a different database (MySQL for example) instead of Postgres.

Now that we have our ORM ready, let's build our persistence layer. Again, we can keep everything in the same file, chat.alusus. Our persistence layer looks like this:

//==============================================================================
// Database

macro envVarOrDefault[varName, defaultVal] CharsPtr().{
    this = System.getEnv(varName);
    if this == 0 this = defaultVal;
}

@model["messages", 1]
class MessageModel {
    Rows.define_model_essentials[];

    @VarChar["1024"]
    @notNull
    @column
    def message: String;

    @BigInteger
    @notNull
    @column["creation_date"]
    def creationDate: Int[64];
}

def db: Rows.Db;
def schema: {
    MessageModel
};

function initializeDatabase {
    db.init(closure(d: ref[SrdRef[Rows.Driver]]) {
        d = getDbDriver();
    });
    db.schemaBuilder[schema].migrate();
}

function getDbDriver(): SrdRef[Rows.Driver] {
    def driver: SrdRef[Rows.Driver] = Rows.PostgresqlDriver(Rows.ConnectionParams().{
        dbName = String(envVarOrDefault["CHAT_DB_NAME", "alusus"]).trim();
        userName = String(envVarOrDefault["CHAT_DB_USERNAME", "alusus"]).trim();
        password = String(envVarOrDefault["CHAT_DB_PASSWORD", "alusus"]).trim();
        host = String(envVarOrDefault["CHAT_DB_HOST", "0.0.0.0"]).trim();
        port = String.parseInt(envVarOrDefault["CHAT_DB_PORT", "5432"]);
    });
    if !driver.isConnected() {
        System.fail(1, String("Error connecting to DB: ") + driver.getLastError());
    }
    return driver;
}
Enter fullscreen mode Exit fullscreen mode

Our model class is called MessageModel and it has two fields: message and creationDate. The getDbDriver function instantiates the Postgres driver with the right connection params. The initializeDatabase initializes the driver and also calls the migrate function of the schema builder to make sure the table is created.

With our data model ready, it's time to use it in the backend endpoints. We will no longer need the global array. Instead, we'll write the data to the database. We also won't need the mutex since the database engine internally handles synchronization. The backend will now look like the following:

//==============================================================================
// Backend

def MAX_MESSAGES: 12;

@beEndpoint["POST", "/messages"]
func postMessages (conn: ptr[Http.Connection]) {
    def postData: array[Char, 1024];
    def postDataSize: Int = Http.read(conn, postData~ptr, postData~size);
    def msg: MessageModel;
    msg.message = String(postData~ptr, postDataSize);
    msg.creationDate = Time.getTimestamp(0);
    db.save[MessageModel](msg);
    Http.print(conn, "HTTP/1.1 200 Ok\r\n\r\n");
}

@beEndpoint["GET", "/messages"]
func getMessages (conn: ptr[Http.Connection]) {
    // Fetch messages from DB.
    def messages: Array[SrdRef[MessageModel]] = db
        .from[MessageModel]
        .order[-creationDate]
        .select()
        .slice(0, MAX_MESSAGES);
    def response: StringBuilder(1024 * 10, 1024 * 5);
    def i: Int;
    for i = messages.getLength() - 1, i >= 0, --i {
        if i != messages.getLength() - 1 response += "\n";
        response += "- ";
        response += messages(i).message;
    }
    // Delete older messages.
    if messages.getLength() > 0 {
        db.from[MessageModel].where[creationDate < messages(messages.getLength() - 1).creationDate].delete();
    }
    // Send response.
    Http.print(conn, "HTTP/1.1 200 Ok\r\n");
    Http.print(conn, "Content-Type: text/plain\r\n");
    Http.print(conn, "Cache-Control: no-cache\r\n");
    Http.print(conn, "Content-Length: %d\r\n\r\n", response.getLength());
    Http.print(conn, response);
}
Enter fullscreen mode Exit fullscreen mode

The main difference from the prev version is in how we save and load the data. We now save the data by instantiating a MessageModel object, set its member vars with the right values, then call db.save[MessageModel](msg). Loading the data is done using the db.from[MessageModel].order[-creationDate].select() statement, which loads it ordered descendingly by the creation date. For this example we continue to limit the messages to 12, but this limit can be changed or removed altogether. Deleting the old records is done by the following:

db.from[MessageModel].where[creationDate < messages(messages.getLength() - 1).creationDate].delete();
Enter fullscreen mode Exit fullscreen mode

Which deletes all the messages that have a creation date older than the 12th message in the loaded data.

That's it, the app is ready to save messages. You just need to make sure a Postgres server is running and the right connection params set in the code, then you can run the code.

Adding Users and Rooms

We have a fully functioning chat application, but with no user authentication and with only one global room. It's now time to authenticate users and allow creating separate rooms. This will require updating the data model. We will add two more models, RoomModel and UserModel. Each of these models has a name (room name or username) and a password field. To avoid complexity we won't do any form of extra security measures needed in production apps and will stick with just password based authentication and will also just store the password in plain text, which is not a valid security method for production apps but is enough for this tutorial. Our MessageModel will also have two foriegn keys, username and roomName. The data model will now look like this:

@model["rooms", 1]
class RoomModel {
    Rows.define_model_essentials[];

    @VarChar["256"]
    @primaryKey
    @column
    def name: String;

    @VarChar["1024"]
    @notNull
    @column
    def password: String;
}

@model["users", 1]
class UserModel {
    Rows.define_model_essentials[];

    @VarChar["256"]
    @primaryKey
    @column
    def username: String;

    @VarChar["1024"]
    @notNull
    @column
    def password: String;
}

@model["messages", 2]
class MessageModel {
    Rows.define_model_essentials[];

    @foreignKey["rooms", "name"]
    @VarChar["256"]
    @notNull
    @column["room_name"]
    def roomName: String;

    @foreignKey["users", "username"]
    @VarChar["256"]
    @notNull
    @column
    def username: String;

    @VarChar["1024"]
    @notNull
    @column
    def message: String;

    @BigInteger
    @notNull
    @column["creation_date"]
    def creationDate: Int[64];

    @migration[1, 2, { RoomModel: 1; UserModel: 1 }]
    function migrateToV2(db: ref[Rows.Db]): SrdRef[Error] {
        db.exec("alter table messages add column room_name varchar(256) ...");
        db.exec("alter table messages add column username varchar(256) ...");
        return SrdRef[Error]();
    }
}

def schema: {
    RoomModel,
    UserModel,
    MessageModel
};
Enter fullscreen mode Exit fullscreen mode

Notice that MessageModel now has a version of 2 and includes the migrateToV2 function. This is only needed if you want to upgrade the previous version to the new version. In this case it's just a tutorial so you can remove this function and just start with a fresh DB.

Our endpoints now will need to deal with multiple values in each request (username, room name, etc) so we'll need to use JSON request bodies rather than plain text as we did so far. Let's import the JSON
library at the beginning of our app:

Apm.importFile("Alusus/Json");
Enter fullscreen mode Exit fullscreen mode

We also need to have an endpoint to validate the credentials. To keep the app simple we will just have a single login endponit where you enter your username, password, room name, and room password. If the user does not exist, it will be created with the given password. If it does exist, then we'll just the password against the existing user and reject if the password doesn't match. We will do the same for
room name. Our POST /validate-creds endpoint looks like this:

@beEndpoint["POST", "/validate-creds"]
func validateCreds (conn: ptr[Http.Connection]) {
    def postData: array[Char, 1024];
    def postDataSize: Int = Http.read(conn, postData~ptr, postData~size);
    def body: Json = String(postData~ptr, postDataSize);
    def roomName: String = body("roomName")~cast[String].trim();
    def roomPassword: String = body("roomPassword");
    def userName: String = body("userName")~cast[String].trim();
    def userPassword: String = body("userPassword");
    // Verify or create user.
    def users: Array[SrdRef[UserModel]] = db.from[UserModel].where[username == userName].select();
    if users.getLength() == 0 {
        db.save[UserModel](UserModel().{
            username = userName;
            password = userPassword;
        });
    } else if users(0).password != userPassword {
        Http.print(conn, "HTTP/1.1 401 Unauthorized\r\n\r\n");
        return;
    }
    // Verify or create room.
    def rooms: Array[SrdRef[RoomModel]] = db.from[RoomModel].where[name == roomName].select();
    if rooms.getLength() == 0 {
        db.save[RoomModel](RoomModel().{
            name = roomName;
            password = roomPassword;
        });
    } else if rooms(0).password != roomPassword {
        Http.print(conn, "HTTP/1.1 401 Unauthorized\r\n\r\n");
        return;
    }
    // Successful
    Http.print(conn, "HTTP/1.1 200 Ok\r\n\r\n");
}
Enter fullscreen mode Exit fullscreen mode

The /validate-creds endpoint will be used by a login page that will validate the entered creds before taking the user to the chat page. The endoints that receive and return messages will also need to be updated to include the credentials in the body since we aren't creating any auth tokens. These will be updated in a similar way to the /validate-creds endpoint, so we won't list the code here to keep the article a bit shorter. The full final code for the entire app will be listed below.

In the front end we will define the following global variables:

def userName: String;
def userPassword: String;
def roomName: String;
def roomPassword: String;
Enter fullscreen mode Exit fullscreen mode

This is so that we keep the creds entered by user in the login screen to be used later on during the chat. We will have a new function called login that will be responsible for creating the login form and waiting until the user has entered the creds and the creds have been validated by the backend. The function looks like this:

func login {
    def success: SrdRef[Promise[Int]] = Promise[Int].new();
    def notification: SrdRef[Text];
    def roomName: SrdRef[Input];
    def roomPassword: SrdRef[Input];
    def userName: SrdRef[Input];
    def userPassword: SrdRef[Input];

    Window.instance.setView(Box().{
        style.{
            height = Length.percent(100);
            height = Length.percent(100);
            display = Display.FLEX;
            layout = Layout.COLUMN;
            justify = Justify.CENTER;
            align = Align.CENTER;
        };
        addChildren({
            Text().{
                notification = this;
                style.{
                    margin = Length4.px(10);
                    fontColor = Color("f00");
                };
            },
            Input().{
                userName = this;
                style.margin = Length4.px(10);
                placeholder = String("Username");
            },
            Input().{
                userPassword = this;
                style.margin = Length4.px(10);
                placeholder = String("Password");
            },
            Input().{
                roomName = this;
                style.margin = Length4.px(10);
                placeholder = String("Room Name");
            },
            Input().{
                roomPassword = this;
                style.margin = Length4.px(10);
                placeholder = String("Room Secret");
            },
            Button(String("Submit")).{
                style.margin = Length4.px(10);
                onClick.connect(closure (widget: ref[Widget], payload: ref[Int]) {
                    notification.setText(String());
                    def json: StringBuilder[JsonStringBuilderMixin](1024, 1025);
                    json.format(
                        "{ \"userName\": %js, \"userPassword\": %js, \"roomName\": %js, \"roomPassword\": %js }",
                        userName.getText().trim(),
                        userPassword.getText(),
                        roomName.getText().trim(),
                        roomPassword.getText()
                    );
                    sendRequest(
                        "POST", "/validate-creds", "Content-Type: application/text", json, 10000,
                        closure (json: Json) {
                            def status: Int = json("eventData")("status");
                            if status == 200 {
                                Root.userName = userName.getText().trim();
                                Root.userPassword = userPassword.getText();
                                Root.roomName = roomName.getText().trim();
                                Root.roomPassword = roomPassword.getText();
                                success.resolve(0);
                            } else {
                                notification.setText(String("Login failed!"));
                            }
                        }
                    );
                });
            }
        });
    });

    await(success);
}
Enter fullscreen mode Exit fullscreen mode

Overall the code is quite similar to the code of the chat screen, but the thing to notice here is the success promise instantiated at the beginning. At the end this promise is awaited by the await function, which transfers control to the message loop and waits there until the promise is resolved before returning.

The code of the UI's entry point is similar to how it was, except that it now calls login upfront, and that the body of the HTTP requests are now in JSON format and includes the credentials. JSON bodies are constructed like this:

def getMessagesJson: String = StringBuilder[JsonStringBuilderMixin](1024, 1025).{
    format(
        "{ \"userName\": %js, \"userPassword\": %js, \"roomName\": %js, \"roomPassword\": %js }",
        userName, userPassword, roomName, roomPassword
    );
};
Enter fullscreen mode Exit fullscreen mode

That's it; we now have a chat app with users and rooms secured by passwords. The full code of the app now looks like this:

import "Srl/String";
import "Srl/Time";
import "Apm";
Apm.importFile("Alusus/Rows", { "Rows.alusus", "Drivers/Postgresql.alusus" });
Apm.importFile("Alusus/WebPlatform");
Apm.importFile("Alusus/Json");
use Srl;
use WebPlatform;
use Promises;

//==============================================================================
// Database

macro envVarOrDefault[varName, defaultVal] CharsPtr().{
    this = System.getEnv(varName);
    if this == 0 this = defaultVal;
}

@model["rooms", 1]
class RoomModel {
    Rows.define_model_essentials[];

    @VarChar["256"]
    @primaryKey
    @column
    def name: String;

    @VarChar["1024"]
    @notNull
    @column
    def password: String;
}

@model["users", 1]
class UserModel {
    Rows.define_model_essentials[];

    @VarChar["256"]
    @primaryKey
    @column
    def username: String;

    @VarChar["1024"]
    @notNull
    @column
    def password: String;
}

@model["messages", 2]
class MessageModel {
    Rows.define_model_essentials[];

    @foreignKey["rooms", "name"]
    @VarChar["256"]
    @notNull
    @column["room_name"]
    def roomName: String;

    @foreignKey["users", "username"]
    @VarChar["256"]
    @notNull
    @column
    def username: String;

    @VarChar["1024"]
    @notNull
    @column
    def message: String;

    @BigInteger
    @notNull
    @column["creation_date"]
    def creationDate: Int[64];

    @migration[1, 2, { RoomModel: 1; UserModel: 1 }]
    function migrateToV2(db: ref[Rows.Db]): SrdRef[Error] {
        db.exec("alter table messages add column room_name varchar(256) not null references rooms(name)");
        db.exec("alter table messages add column username varchar(256) not null references users(username)");
        return SrdRef[Error]();
    }
}

def db: Rows.Db;
def schema: {
    RoomModel,
    UserModel,
    MessageModel
};

function initializeDatabase {
    db.init(closure(d: ref[SrdRef[Rows.Driver]]) {
        d = getDbDriver();
    });
    db.schemaBuilder[schema].migrate();
}

function getDbDriver(): SrdRef[Rows.Driver] {
    def driver: SrdRef[Rows.Driver] = Rows.PostgresqlDriver(Rows.ConnectionParams().{
        dbName = String(envVarOrDefault["CHAT_DB_NAME", "alusus"]).trim();
        userName = String(envVarOrDefault["CHAT_DB_USERNAME", "alusus"]).trim();
        password = String(envVarOrDefault["CHAT_DB_PASSWORD", "alusus"]).trim();
        host = String(envVarOrDefault["CHAT_DB_HOST", "0.0.0.0"]).trim();
        port = String.parseInt(envVarOrDefault["CHAT_DB_PORT", "5432"]);
    });
    if !driver.isConnected() {
        System.fail(1, String("Error connecting to DB: ") + driver.getLastError());
    }
    return driver;
}

//==============================================================================
// Backend

def MAX_MESSAGES: 12;

@beEndpoint["POST", "/validate-creds"]
func validateCreds (conn: ptr[Http.Connection]) {
    def postData: array[Char, 1024];
    def postDataSize: Int = Http.read(conn, postData~ptr, postData~size);
    def body: Json = String(postData~ptr, postDataSize);
    def roomName: String = body("roomName")~cast[String].trim();
    def roomPassword: String = body("roomPassword");
    def userName: String = body("userName")~cast[String].trim();
    def userPassword: String = body("userPassword");
    // Verify or create user.
    def users: Array[SrdRef[UserModel]] = db.from[UserModel].where[username == userName].select();
    if users.getLength() == 0 {
        db.save[UserModel](UserModel().{
            username = userName;
            password = userPassword;
        });
    } else if users(0).password != userPassword {
        Http.print(conn, "HTTP/1.1 401 Unauthorized\r\n\r\n");
        return;
    }
    // Verify or create room.
    def rooms: Array[SrdRef[RoomModel]] = db.from[RoomModel].where[name == roomName].select();
    if rooms.getLength() == 0 {
        db.save[RoomModel](RoomModel().{
            name = roomName;
            password = roomPassword;
        });
    } else if rooms(0).password != roomPassword {
        Http.print(conn, "HTTP/1.1 401 Unauthorized\r\n\r\n");
        return;
    }
    // Successful
    Http.print(conn, "HTTP/1.1 200 Ok\r\n\r\n");
}

@beEndpoint["POST", "/add-message"]
func postMessages (conn: ptr[Http.Connection]) {
    def postData: array[Char, 4096];
    def postDataSize: Int = Http.read(conn, postData~ptr, postData~size);
    def body: Json = String(postData~ptr, postDataSize);
    if not validateCreds(body) {
        Http.print(conn, "HTTP/1.1 401 Unauthorized\r\n\r\n");
        return;
    }
    def msg: MessageModel;
    msg.username = body("userName")~cast[String].trim();
    msg.roomName = body("roomName")~cast[String].trim();
    msg.message = body("message");
    msg.creationDate = Time.getTimestamp(0);
    db.save[MessageModel](msg);
    Http.print(conn, "HTTP/1.1 200 Ok\r\n\r\n");
}

@beEndpoint["POST", "/get-messages"]
func getMessages (conn: ptr[Http.Connection]) {
    def postData: array[Char, 4096];
    def postDataSize: Int = Http.read(conn, postData~ptr, postData~size);
    def body: Json = String(postData~ptr, postDataSize);
    if not validateCreds(body) {
        Http.print(conn, "HTTP/1.1 401 Unauthorized\r\n\r\n");
        return;
    }
    // Fetch messages from DB.
    def messages: Array[SrdRef[MessageModel]] = db
        .from[MessageModel]
        .where[roomName == body("roomName")~cast[String].trim()]
        .order[-creationDate]
        .select()
        .slice(0, MAX_MESSAGES);
    def response: StringBuilder(1024 * 10, 1024 * 5);
    def i: Int;
    for i = messages.getLength() - 1, i >= 0, --i {
        if i != messages.getLength() - 1 response += "\n";
        response.format("- %s: %s", messages(i).username.buf, messages(i).message.buf);
    }
    // Delete older messages.
    if messages.getLength() > 0 {
        db.from[MessageModel]
            .where[roomName == body("roomName")~cast[String].trim() and
                creationDate < messages(messages.getLength() - 1).creationDate]
            .delete();
    }
    // Send response.
    Http.print(conn, "HTTP/1.1 200 Ok\r\n");
    Http.print(conn, "Content-Type: text/plain\r\n");
    Http.print(conn, "Cache-Control: no-cache\r\n");
    Http.print(conn, "Content-Length: %d\r\n\r\n", response.getLength());
    Http.print(conn, response);
}

func validateCreds (json: Json): Bool {
    def roomName: String = json("roomName")~cast[String].trim();
    def roomPassword: String = json("roomPassword");
    def userName: String = json("userName")~cast[String].trim();
    def userPassword: String = json("userPassword");
    def users: Array[SrdRef[UserModel]] = db.from[UserModel].where[username == userName].select();
    if users.getLength() == 0 or users(0).password != userPassword {
        return false;
    }
    def rooms: Array[SrdRef[RoomModel]] = db.from[RoomModel].where[name == roomName].select();
    if rooms.getLength() == 0 or rooms(0).password != roomPassword {
        return false;
    }
    return true;
}


//==============================================================================
// Frontend Components

class Header {
    @injection def component: Component;

    handler this~init() {
        this.view = Box({}).{
            style.{
                padding = Length4.pt(4);
                borderWidth = Length4.pt(0, 0, 1.5, 0);
                borderStyle = BorderStyle.SOLID;
                borderColor = Color("000");
                justify = Justify.START;
                display = Display.FLEX;
                layout = Layout.COLUMN;
                background = Background(Color("fff"));
            };
            addChildren({
                Text(String("Wasm Chat")).{
                    style.fontSize = Length.pt(18.0);
                }
            });
        };
    }

    handler this_type(): SrdRef[Header] {
        return SrdRef[Header].construct();
    }
}

class TextEntry {
    @injection def component: Component;

    def onNewEntry: closure (String);
    def textInput: SrdRef[TextInput];

    handler this~init() {
        def self: ref[this_type](this);

        this.view = Box({}).{
            style.{
                display = Display.FLEX;
                layout = Layout.ROW;
                justify = Justify.SPACE_BETWEEN;
            };
            addChildren({
                TextInput().{
                    self.textInput = this;
                    style.{
                        flex = Flex(1);
                        margin = Length4.px(10);
                        height = Length.px(50);
                        fontSize = Length.pt(12.0);
                        borderStyle = BorderStyle.SOLID;
                        borderColor = Color("000");
                        borderWidth = Length4.px(1.5);
                    };
                    onKeyUp.connect(closure (widget: ref[TextInput], payload: ref[String]) {
                        if payload == "Shift+Enter" {
                            def newData: String = widget.getText().trim();
                            widget.setText(String());
                            if not self.onNewEntry.isNull() self.onNewEntry(newData);
                        }
                    });
                },
                Button(String("Send")).{
                    style.{
                        height = Length.px(50);
                        width = Length.px(50);
                        fontSize = Length.px(16.0);
                        justify = Justify.CENTER;
                        margin = Length4.px(10, 10, 10, 0);
                    };
                    onClick.connect(closure (widget: ref[Widget], payload: ref[Int]) {
                        def newData: String = self.textInput.getText().trim();
                        self.textInput.setText(String());
                        if not self.onNewEntry.isNull() self.onNewEntry(newData);
                    });
                }
            });
        };
    }

    handler this_type(): SrdRef[TextEntry] {
        return SrdRef[TextEntry].construct();
    }
}

//==============================================================================
// Frontend Pages

def userName: String;
def userPassword: String;
def roomName: String;
def roomPassword: String;

func login {
    def success: SrdRef[Promise[Int]] = Promise[Int].new();
    def notification: SrdRef[Text];
    def roomName: SrdRef[Input];
    def roomPassword: SrdRef[Input];
    def userName: SrdRef[Input];
    def userPassword: SrdRef[Input];

    Window.instance.setView(Box().{
        style.{
            height = Length.percent(100);
            height = Length.percent(100);
            display = Display.FLEX;
            layout = Layout.COLUMN;
            justify = Justify.CENTER;
            align = Align.CENTER;
        };
        addChildren({
            Text().{
                notification = this;
                style.{
                    margin = Length4.px(10);
                    fontColor = Color("f00");
                };
            },
            Input().{
                userName = this;
                style.margin = Length4.px(10);
                placeholder = String("Username");
            },
            Input().{
                userPassword = this;
                style.margin = Length4.px(10);
                placeholder = String("Password");
            },
            Input().{
                roomName = this;
                style.margin = Length4.px(10);
                placeholder = String("Room Name");
            },
            Input().{
                roomPassword = this;
                style.margin = Length4.px(10);
                placeholder = String("Room Secret");
            },
            Button(String("Submit")).{
                style.margin = Length4.px(10);
                onClick.connect(closure (widget: ref[Widget], payload: ref[Int]) {
                    notification.setText(String());
                    def json: StringBuilder[JsonStringBuilderMixin](1024, 1025);
                    json.format(
                        "{ \"userName\": %js, \"userPassword\": %js, \"roomName\": %js, \"roomPassword\": %js }",
                        userName.getText().trim(),
                        userPassword.getText(),
                        roomName.getText().trim(),
                        roomPassword.getText()
                    );
                    sendRequest(
                        "POST", "/validate-creds", "Content-Type: application/text", json, 10000,
                        closure (json: Json) {
                            def status: Int = json("eventData")("status");
                            if status == 200 {
                                Root.userName = userName.getText().trim();
                                Root.userPassword = userPassword.getText();
                                Root.roomName = roomName.getText().trim();
                                Root.roomPassword = roomPassword.getText();
                                success.resolve(0);
                            } else {
                                notification.setText(String("Login failed!"));
                            }
                        }
                    );
                });
            }
        });
    });

    await(success);
}

@uiEndpoint["/"]
@title["Wasm Chat"]
func main {
    def onFetch: closure (json: Json);

    Window.instance.style.{
        padding = Length4.pt(0);
        margin = Length4.pt(0);
    };

    login();

    def getMessagesJson: String = StringBuilder[JsonStringBuilderMixin](1024, 1025).{
        format(
            "{ \"userName\": %js, \"userPassword\": %js, \"roomName\": %js, \"roomPassword\": %js }",
            userName, userPassword, roomName, roomPassword
        );
    };

    Window.instance.setView(Box({}).{
        style.{
            height = Length.percent(100);
            justify = Justify.SPACE_BETWEEN;
            display = Display.FLEX;
            layout = Layout.COLUMN;
            background = Background(Color("aaa"));
        };
        addChildren({
            Header(),
            Text(String()).{
                style.{
                    flex = Flex(1);
                    width = Length.percent(100) - Length.px(20);
                    margin = Length4.px(10, 10, 0, 10);
                    fontSize = Length.pt(20.0);
                    borderStyle = BorderStyle.SOLID;
                    borderColor = Color("000");
                    borderWidth = Length4.px(1.5);
                    background = Background(Color("fff"));
                };
                onFetch = closure (json: Json) {
                    def status: Int[64] = json("eventData")("status");
                    if status >= 200 and status < 300 {
                        def data: String = json("eventData")("body");
                        if this.getText() != data {
                            this.setText(data);
                        }
                    }
                };
            },
            TextEntry().{
                onNewEntry = closure (newData: String) {
                    def postMessagesJson: StringBuilder[JsonStringBuilderMixin](1024, 1025);
                    postMessagesJson.format(
                        "{ \"userName\": %js, \"userPassword\": %js, \"roomName\": %js, \"roomPassword\": %js, \"message\": %js }",
                        userName, userPassword, roomName, roomPassword, newData
                    );
                    sendRequest(
                        "POST", "/add-message", "Content-Type: application/json", postMessagesJson, 10000,
                        closure (Json) {
                            sendRequest("POST", "/get-messages", "Content-Type: application/json", getMessagesJson, 500, onFetch);
                        }
                    );
                };
            };
        })
    });

    startTimer(500000, closure (json: Json) {
        sendRequest("POST", "/get-messages", "Content-Type: application/json", getMessagesJson, 500, onFetch);
    });
    sendRequest("POST", "/get-messages", "Content-Type: application/json", getMessagesJson, 500, onFetch);

    runEventLoop();
}

//==============================================================================
// Entry Point

initializeDatabase();
Console.print("Starting server on port 8000...\nURL: http://localhost:8000/\n");
buildAndRunServer(Array[CharsPtr]({ "listening_ports", "8000", "static_file_max_age", "0" }));
Enter fullscreen mode Exit fullscreen mode

Deployment

It's time to deploy our app to some server. The recommended method of deployment is to generate an executable for this app, then deploy the executable, rather than deploy the source code and have alusus itself installed on the production machine. The easiest way to do this is to use Nashir to build the app and deploy it to Alusus Net, a low cost hosting service geared towards hosting Alusus based applications. To do this, first we need to import Nashir into our app:

Apm.importFile("Alusus/Nashir", { "Nashir.alusus", "PublishDrivers/AlususNet.alusus" });
Enter fullscreen mode Exit fullscreen mode

Nashir is a general building and publishing tool that can support multiple publishing targets, so we need to tell it which hosting service we are publishing on, and that's why we are importing the AlususNet publish driver. With this imported, we can now use it in the program's entry point. All we need to do is replace the few lines in the entry point with the following:

func initializeServer {
    initializeDatabase();
}

Nashir.setup[Nashir.Config().{
    projectName = "chat";
    projectVersion = "v1";
    serverPort = 8000;
    initializer~no_deref = initializeServer~ast;
    dependencies.add(Rows.PostgresqlDriver.getBuildDependencies())؛
    publishDriver = Nashir.AlususNetPublishDriver();
}];
ProgArg.parse(2);
Enter fullscreen mode Exit fullscreen mode

We do not need to create an entry point nor to manually start the server; Nashir will do all that for us, and it will give us the option of running the app directly, or generating an executable for it. The initializeDatabase call is now moved to a function, and that function is passed to Nashir through its config. We need to do this because we no longer want to initialize the DB directly; instead, we want the DB to be initialized by the new program entry point that will be generated automatically for us by Nashir. This will be clearer once you understand that the code at the root scope only gets executed during the JIT phase; generated executables on the other hand start from a specific function, then includes all its dependencies. This is one of the unique features of Alusus, and it allows projects to generate multiple binary builds from a single unified project, allowing Nashir in this case to generate separate builds for the front end and the back end.

The rest of the arguments in the call to Nashir.setup is pretty self explanatory, you give it the basic info of the project, the list of binary dependencies, and an instance of the publish driver to use for the actual publishing after the build is complete.

Nashir depends on a library called ProgArg, which is a library for managing program arguments. The final step is to call ProgArg.parse giving it the index from which to start parsing arguments. We start from 2 because arg 0 is alusus binary and arg 1 is the name of the source file. The call to ProgArg.parse will trigger the right action based on the user's command line arguments.

At this poine, when you run alusus chat.alusus you will be presented with the list of commands available to you. Running alusus chat.alusus start will start the application on your machine. The build command will generate an executable, and the publish command will generate the executable then publish it on Alusus Net. The remaining publishing steps will happen interactively in the terminal, where you will be asked to enter credentials for the account that you can create on https://alusus.net, select payment
options and accept terms of service, then the publishing will start and your app will be available online within minutes under a subdomain of alusus.net, with HTTPS automatically enabled. In order to let the app connect to the DB within the container you'll need to connect using the system user rather than a Postgres user, which means setting your DB connection params as follows:

  • DB name: "root"
  • DB user: "root"
  • DB password: ""
  • DB host: ""
  • DB port: 0

You can either update the code so that it defaults to these values, or keep it as is and set these values as env vars from the Alusus Net dashboard, which is the recommended approach. Notice that Alusus Net won't let you add an env var with a value of "", so instead use a space string, i.e. " " and this will be trimmed in the getDbDriver function. Once you set these env vars in the dashboard make sure to restart the container to let the app pick those new values. The container will take 15 seconds or so to start up after which the app will be ready for use and you can now create rooms and chat with your friends across the world.

Stay tuned for upcoming articles where I will be explaining in more details what happens behind the scenes in this app, and what makes Alusus programming language a truely unique and promising programming language.

Top comments (0)