Building SPA web app with Rust

I wanted to build a SPA (Single Page Application), that is using rust files only, no JavaScript or HTML files.

I got the ide after noting that WASM code should be called from JS using fetch, so though may using XHR or Fetch can be used to call required functionality from Rust and forms/pages UI can be called as html responce as well.

I'd 2 issues, that are:

  1. The need of submitting forms data as json, so I created global function for this to avoid multiple codingof the same lines.
  2. Loading the first page, as nthing can be done before window.loaded so even the first page had been loaded by recievig XHR responce

SO, I coded the below for:

  1. Creating the global function toJSONString(form),
  2. Calling the '/first' UI and display it
#[get("/")]
fn index() -> content::Html<&'static str> {
    content::Html(r#"
    <script>
        (function (root, factory) {
            if ( typeof define === 'function' && define.amd ) {
                define([], factory(root));
            } else if ( typeof exports === 'object' ) {
                module.exports = factory(root);
            } else {
                root.oryxPlugin = factory(root);
            }
        })(typeof global !== "undefined" ? global : this.window || this.global, function (root) {

            'use strict';

            var oryxPlugin = {}; // Object for public APIs
            
            oryxPlugin.toJSONString = function(form){
                var obj = {};
                var elements = form.querySelectorAll( "input, select, textarea" );
                for( var i = 0; i < elements.length; ++i ) {
                    var element = elements[i];
                    var name = element.name;
                    var value = element.value;

                    if( name ) {
                        obj[ name ] = value;
                    }
                }
            return JSON.stringify( obj );
            };
            return oryxPlugin;
        });

        var xhr = new XMLHttpRequest();
        xhr.onreadystatechange = function() {
            var s = document.createElement('script');
            s.type = 'text/javascript';
            if (this.readyState == 4 && this.status == 200) {
                s.appendChild(document.createTextNode(this.responseText));
               document.body.appendChild(s);
            }
        };
        xhr.open("GET", '/first');
        xhr.send();
    </script>
    "#)
}

And coded the below as '/first' page UI loading, in which I splitted the page into 2 parts:

  1. header, to be used for menu and so
  2. context, to be empty and refilled with each new page content
#[get("/first")]
fn first() -> (content::Html<&'static str>) {
    content::Html(r#"
        var hdr = document.createElement("div");
        hdr.id = 'header'
        hdr.innerHTML= `
        <h1>Welcome to my app</h1><br>
        <button id = 'btn'>load second screen</button>
        `;

        hdr.querySelector('#btn').onclick = function(){
            var context = document.querySelector('#context')
            var xhr = new XMLHttpRequest();
            xhr.open("GET", '/second');

            xhr.onreadystatechange = function() {
                var s = document.createElement('script');
                s.type = 'text/javascript';
                if (this.readyState == 4 && this.status == 200) {
                    s.appendChild(document.createTextNode(this.responseText));
                    document.body.appendChild(s);
            }
        };
            xhr.send();
        }
        document.body.appendChild(hdr);


        var context = document.createElement("div");
        context.id = 'context'

        var form = document.createElement("form");
        var button = document.createElement("button");
        form.innerHTML =`
            <input type="text" name="fname" />
            <input type="text" name="lname" />
        `
        button.onclick = function(){
            var dataContainer = oryxPlugin.toJSONString(form);
            console.log(dataContainer);

            var xhr = new XMLHttpRequest();
            xhr.open("POST", '/call', true);

            xhr.onreadystatechange = function() {
                if (this.readyState == 4 && this.status == 200) {
                    var obj = JSON.parse(this.responseText);
                    console.log('Returned string is: ' + obj.fname + ', ' + obj.lname);
                }
            };
            xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8");
            xhr.send(dataContainer);
        };
        button.innerHTML ='Click HERE'
        context.appendChild(<h5>This is the first screen</h5><br>`)
        context.appendChild(form)
        context.appendChild(button)
        document.body.appendChild(context);
    "#)
}

Each page after the first page, can be coded similar to the below, where the contents of the context elemt are keep updating:

#[get("/second")]
fn second() -> (content::Html<&'static str>) {
    content::Html(r#"
        var div = document.createElement("div");
        div.innerHTML= `
        <h5>This is the second screen</h5><br>
        `;
        while (context.hasChildNodes()) {
            context.removeChild(context.lastChild);
        }
        context.appendChild(div);
    "#)
}

To handle json recieved/sending, below code is for doing both steps:

#[derive(Serialize, Deserialize, Debug)]
struct Name {
    fname: String,
    lname: String,
}

#[post("/call", format = "application/json", data = "<name>")]
fn call(name: Json<Name>) -> Json<Name> { 
    let user = &name;
    println!("Form field is: {:?} ", user);
    dbg!(user);
    println!("Fist name: {0}, Family name: {1}",
     user.fname, user.lname);
    let x = Name{
        fname : name.fname.to_owned(),
        lname : name.lname.to_owned()
        };
    Json(x)
}

This had been done using rocket.rs, so the main.rs header should be:

#![feature(proc_macro_hygiene, decl_macro)]

#[macro_use] extern crate rocket;
#[macro_use] extern crate serde_derive;

use rocket_contrib::json::Json;

use rocket::response::content;

And the routes to be defined as:

fn main() {
    rocket::ignite()
        .mount("/", routes![index, call, first, second])
        .launch();
}

Not to forget the Cargo.toml is:

[package]
name = "workshop"
version = "0.1.0"
authors = ["Hasan Yousef"]
edition = "2018"

[dependencies]
rocket = "0.4.0"
serde = "1.0"
serde_json = "1.0"
serde_derive = "1.0"

[dependencies.rocket_contrib]
version = "0.4.0"
default-features = false
features = ["json"]

Nightly rust is required for rocket.rs apps till now.

I liked to share this with the community for comments/feedbacks/guidlines/ideas.

1 Like