Front-End Webdev in Rust

by Ilya Lakhin
for Rust Hack and Learn Berline meetups
June 6, 2024

Basic WASM Setup

// Exports to Rust
const JS_EXPORTS = {
    export_section: {
        pong: () => {
            console.log('pong');
        },
    },
};

// Loading assembly from the server
const assembly = fetch('assembly.wasm');

WebAssembly
    .instantiateStreaming(assembly, JS_EXPORTS)
    .then(({instance}) => {
        // Exports from Rust
        const rsExports = instance.exports;

        rsExports.ping();
    });

index.js

#[link(wasm_import_module = "export_section")]
extern "C" {
    fn pong();
}

#[no_mangle]
unsafe extern "C" fn ping() {
    unsafe { pong() };
}

assembly.rs

rustup target add wasm32-unknown-unknown

cargo build --target wasm32-unknown-unknown --release

 

 

in Cargo.toml:

[lib]
crate-type = ["cdylib"]

WASM Target

  • Rust code is single-threaded.
  • No access to external environment (no "println!").
  • No built-in browser features too (DOM, console, etc).
  • To access browser's API, this API has to be exported by JavaScript-side.
  • But still you can use the majority of the standard library.
function exportString(string) {
    const encoded = new TextEncoder().encode(string);

    const head = module.string_alloc(encoded.length);
    const target = new Uint8Array(module.memory.buffer, head, encoded.length);

    target.set(encoded);
}

WASM Target

static mut EXTERNAL_STRING: Vec<u8> = Vec::new();

#[no_mangle]
unsafe extern "C" fn string_alloc(len: u32) -> *const u8 {
    let len = len as usize;

    let external_string = unsafe { &mut EXTERNAL_STRING };

    external_string.reserve(len);

    unsafe { external_string.set_len(len) };

    external_string.as_ptr()
}

fn import_string() -> String {
    let external_string = unsafe { &mut EXTERNAL_STRING };

    let bytes = replace(external_string, Vec::new());

    unsafe { String::from_utf8_unchecked(bytes) }
}

WASM to JS Bridge

Wasm Bindgen generates API bridge between Rust functions and the JavaScript environment.

use wasm_bindgen::prelude::*;

#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = console)]
    fn log(s: &str);
}

log("Hello from Rust!");
  • The "js_sys" crate.
    Bindings of all standard, built-in objects.

     
  • The "web_sys" crate.
    Bingings of the browser's web APIs (DOM, Fetch API, WebGL, etc)

     
  • The "wasm-bindgen-futures" and "spawn_local" crates.
    Promisifying Rust's async code.

     

Build Tools

Somewhat "Webpacks" for Rust:

  • ​Rustpack
  • Trunk
# An example Trunk.toml with all possible fields along with their defaults.

# A sem-ver version requirement of trunk required for this project
trunk-version = "*"

[build]
# The index HTML file to drive the bundling process.
target = "index.html"
# Build in release mode.
release = false
# The output dir for all final assets.
dist = "dist"
# The public URL from which assets are to be served.
public_url = "/"
# Whether to include hash values in the output file names.
filehash = true
# Whether to inject scripts (and module preloads) into the finalized output.
inject_scripts = true
# Run without network access
offline = false
# Require Cargo.lock and cache are up to date
frozen = false
# Require Cargo.lock is up to date
locked = false
# Control minification
minify = "never" # can be one of: never, on_release, always
# Allow disabling sub-resource integrity (SRI)
no_sri = false

[watch]
# Paths to watch. The `build.target`'s parent folder is watched by default.
watch = []
# Paths to ignore.
ignore = []

[serve]
# The address to serve on.
addresses = ["127.0.0.1"]
# The port to serve on.
port = 8080
# Open a browser tab once the initial build is complete.
open = false
# Disable auto-reload of the web app.
no_autoreload = false
# Disable error reporting
no_error_reporting = false
# Additional headers set for responses.
# headers = { "test-header" = "header value", "test-header2" = "header value 2" }
# Protocol used for autoreload WebSockets connection.
ws_protocol = "ws"
# The certificate/private key pair to use for TLS, which is enabled if both are set.
# tls_key_path = "self_signed_certs/key.pem"
# tls_cert_path = "self_signed_certs/cert.pem"

[clean]
# The output dir for all final assets.
dist = "dist"
# Optionally perform a cargo clean.
cargo = false

[tools]
# Default dart-sass version to download.
sass = "1.69.5"
# Default wasm-bindgen version to download.
wasm_bindgen = "0.2.89"
# Default wasm-opt version to download.
wasm_opt = "version_116"
# Default tailwindcss-cli version to download.
tailwindcss = "3.3.5"

## proxy
# Proxies are optional, and default to `None`.
# Proxies are only run as part of the `trunk serve` command.

[[proxy]]
# This WebSocket proxy example has a backend and ws field. This example will listen for
# WebSocket connections at `/api/ws` and proxy them to `ws://localhost:9000/api/ws`.
backend = "ws://localhost:9000/api/ws"
ws = true

[[proxy]]
# This proxy example has a backend and a rewrite field. Requests received on `rewrite` will be
# proxied to the backend after rewriting the `rewrite` prefix to the `backend`'s URI prefix.
# E.G., `/api/v1/resource/x/y/z` -> `/resource/x/y/z`
rewrite = "/api/v1/"
backend = "http://localhost:9000/"

[[proxy]]
# This proxy specifies only the backend, which is the only required field. In this example,
# request URIs are not modified when proxied.
backend = "http://localhost:9000/api/v2/"

[[proxy]]
# This proxy example has an insecure field. In this example,
# connections to https://localhost:9000/api/v3/ will not have certificate validation performed.
# This is useful for development with a server using self-signed certificates.
backend = "https://localhost:9000/api/v3/"
insecure = true

[[proxy]]
# This proxy example has the no_system_proxy field. In this example,
# connections to https://172.16.0.1:9000/api/v3/ will bypass the system proxy.
# This may be useful in cases where a local system has a proxy configured which cannot be reconfigured, but the
# proxy target (of trunk serve) is not know/accessible by the system's proxy.
backend = "https://172.16.0.1:9000/api/v3/"
no_system_proxy = true

## hooks
# Hooks are optional, and default to `None`.
# Hooks are executed as part of Trunk's main build pipeline, no matter how it is run.

[[hooks]]
# This hook example shows all the current available fields. It will execute the equivalent of
# typing "echo Hello Trunk!" right at the start of the build process (even before the HTML file
# is read). By default, the command is spawned directly and no shell is used.
stage = "pre_build"
command = "echo"
command_arguments = ["Hello", "Trunk!"]

[[hooks]]
# This hook example shows running a command inside a shell. As a result, features such as variable
# interpolation are available. This shows the TRUNK_STAGING_DIR environment variable, one of a set
# of default variables that Trunk inserts into your hook's environment. Additionally, this hook
# uses the build stage, meaning it executes in parallel with all of the existing asset pipelines.
stage = "build"
command = "sh"
command_arguments = ["-c", "echo Staging directory: $TRUNK_STAGING_DIR"]

[[hooks]]
# This hook example shows how command_arguments defaults to an empty list when absent. It also uses
# the post_build stage, meaning it executes after the rest of the build is complete, just before
# the staging directory is copied over the dist directory. This means that it has access to all
# built assets, including the HTML file generated by trunk.
stage = "post_build"
command = "ls"

Web Frameworks

React-Inspired

  • Leptos
  • Yew
  • Dioxus
  • Sauron

Desktop GUIs with web-targets

  • Egui
  • Iced

From https://www.arewewebyet.org/topics/frameworks/

React-Inspired Web Frameworks

use yew::prelude::*;

#[function_component]
fn App() -> Html {
    let counter = use_state(|| 0);
    let onclick = {
        let counter = counter.clone();
        move |_| {
            let value = *counter + 1;
            counter.set(value);
        }
    };

    html! {
        <div>
            <button {onclick}>{ "+1" }</button>
            <p>{ *counter }</p>
        </div>
    }
}

fn main() {
    yew::Renderer::<App>::new().render();
}

Yew example

Typical React-like design:

  1. The system of nested components that build up reactive html.
     
  2. The frameworks maintain virtual html that incrementally updates when the external state is changing.
     
  3. Most of the frameworks use macros that mimic jsx/tsx reactive html embeddings ("html! { ... }" or "view! { ... }").
     
  4. The final virtual html is continuously mapping to the web page's DOM.
     
  5. The API design in general looks familiar to React developers.

Server-Side Rendering

use leptos::*;
use leptos_router::*;

#[component]
pub fn App() -> impl IntoView {
  view! {
    <Router>
      <nav>
        /* ... */
      </nav>
      <main>
        <Routes>
          <Route path="/" view=Home/>
          <Route path="/users" view=Users/>
          <Route path="/users/:id" view=UserProfile/>
          <Route path="/*any" view=|| view! {
            <h1>"Not Found"</h1>
          }/>
        </Routes>
      </main>
    </Router>
  }
}

Leptos example

  1. Application architecture follows the same structure as SPA: nested components, reactive virtual html, etc.
     
  2. But a part of the application rendering is happening on the server-side when the user navigates to particular web page.
     
  3. Therefore, we can use the database and other server-side features when the server's part of the page is rendering.
     
  4. Server-side and client-side components interconnected together seamlessly in the Rust code.
     
  5. When the server-side part is served to the browser, the rest of the user events are being handled in the browser.

(aka, Next.js)

Implications and Risks

Probably, not real risks:

  1. The programmer should be familiar with the web development and Rust.

    Not a risk because many Rust developers came from the webdev world.

     
  2. Rust frameworks require webassembly support.

    But in a nowadays, most of the web-browsers support necessary technologies very well.

     
  3. Bridges slow down application performance.

    Perhaps not significantly if they ever affect. Plus, Rust's inherited performance compensates losses.

Advantages:

  1. Application's logic implemented with Rust, and that is not directly related to the framework setup, is likely to run faster than the same code written in JavaScript.
     
  2. All advantages of Rust programming: modern type safe language, no JS legacies, advanced infrastructure, it's cool!

Implications and Risks

Shortcomings:

  1. JS Components Integration.

    JavaScript infrastructure is full of useful browser components and React extensions. It will be difficult to properly integrate them into the Rust code through the WASM bridges. And even more difficult to maintain.

     
  2. Rust frameworks support continuation.

    React.js passed a proof of time and is backed by Facebook. It is unlikely that the JavaScript infrastructure  will be discontinues anytime soon.

Thank you!

Ilya Lakhin
June 6, 2024

Made with Slides.com