Server-side rendering
Frontend web frameworks allow developers to create web apps in a high-level language and component model. The web app is built into a static website to be rendered in the browser. While many frontend web frameworks are based on JavaScript, such as React and Vue, Rust-based frameworks are also emerging as the Rust language gains traction among developers. Those web frameworks render the HTML DOM UI using the WebAssembly, compiled from Rust source code. They use wasm-bindgen to tie the Rust to the HTML DOM. While these frameworks send .wasm
files to the browser to render the UI on the client side, some provide the additional choice for Server-side rendering. That is to run the WebAssembly code, build the HTML DOM UI on the server, and stream the HTML content to the browser for faster performance and startup time on slow devices and networks.
If you are interested in JavaScript-based Jamstack and SSR frameworks, such as React, please check out our JavaScript SSR chapter.
This article will explore how to render the web UI on the server using WasmEdge. We pick Percy as our framework because it is relatively mature in SSR and Hydration. Percy already provides an example for SSR. It's highly recommended to read it first to understand how it works. The default SSR setup with Percy utilizes a native Rust web server. The Rust code is compiled to machine native code for the server. However, we need a sandbox to host user applications on the server. While we could run native code inside a Linux container (Docker), a far more efficient (and safer) approach is to run the compiled code in a WebAssembly VM on the server, especially considering the rendering code is already compiled into WebAssembly.
Let's go through the steps to run a Percy SSR service in a WasmEdge server.
Assuming we are in the examples/isomorphic
directory, make a new crate beside the existing server
.
cargo new server-wasmedge
You'll receive a warning to let you put the new crate into the workspace, so insert below into members
of [workspace]
. The file is ../../Cargo.toml
.
"examples/isomorphic/server-wasmedge"
With the file open, put these two lines in the bottom:
[patch.crates-io]
wasm-bindgen = { git = "https://github.com/KernelErr/wasm-bindgen.git", branch = "wasi-compat" }
Why do we need a forked wasm-bindgen
? That is because wasm-bindgen
is the required glue between Rust and HTML in the browser. On the server, however, we need to build the Rust code to the wasm32-wasi
target, which is incompatible with wasm-bindgen
. Our forked wasm-bindgen
has conditional configs that remove browser-specific code in the generated .wasm
file for the wasm32-wasi
target.
Then replace the crate's Cargo.toml
with the following content.
[package]
name = "isomorphic-server-wasmedge"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
wasmedge_wasi_socket = "0"
querystring = "1.1.0"
parsed = { version = "0.3", features = ["http"] }
anyhow = "1"
serde = { version = "1.0", features = ["derive"] }
isomorphic-app = { path = "../app" }
The wasmedge_wasi_socket
crate is the socket API of WasmEdge. This project is under development. Next, copy the index.html
file into the crate's root.
cp server/src/index.html server-wasmedge/src/
Then let's create some Rust code to start a web service in WasmEdge! The main.rs
program listens to the request and sends the response via the stream.
use std::io::Write;
use wasmedge_wasi_socket::{Shutdown, TcpListener};
mod handler;
mod mime;
mod response;
fn main() {
let server = TcpListener::bind("127.0.0.1:3000", false).unwrap();
println!("Server listening on 127.0.0.1:3000");
// Simple single-thread HTTP server
// For server with Pool support, see https://github.com/second-state/wasmedge_wasi_socket/tree/main/examples/poll_http_server
loop {
let (mut stream, addr) = server.accept(0).unwrap();
println!("Accepted connection from {}", addr);
match handler::handle_req(&mut stream, addr) {
Ok((res, binary)) => {
let res: String = res.into();
let bytes = res.as_bytes();
stream.write_all(bytes).unwrap();
if let Some(binary) = binary {
stream.write_all(&binary).unwrap();
}
}
Err(e) => {
println!("Error: {:?}", e);
}
};
stream.shutdown(Shutdown::Both).unwrap();
}
}
The handler.rs
parses the received data to the path and queries objects and returns the corresponding response.
use crate::response;
use anyhow::Result;
use parsed::http::Response;
use std::io::Read;
use wasmedge_wasi_socket::{SocketAddr, TcpStream};
pub fn handle_req(stream: &mut TcpStream, addr: SocketAddr) -> Result<(Response, Option<Vec<u8>>)> {
let mut buf = [0u8; 1024];
let mut received_data: Vec<u8> = Vec::new();
loop {
let n = stream.read(&mut buf)?;
received_data.extend_from_slice(&buf[..n]);
if n < 1024 {
break;
}
}
let mut bs: parsed::stream::ByteStream = match String::from_utf8(received_data) {
Ok(s) => s.into(),
Err(_) => return Ok((response::bad_request(), None)),
};
let req = match parsed::http::parse_http_request(&mut bs) {
Some(req) => req,
None => return Ok((response::bad_request(), None)),
};
println!("{:?} request: {:?} {:?}", addr, req.method, req.path);
let mut path_split = req.path.split("?");
let path = path_split.next().unwrap_or("/");
let query_str = path_split.next().unwrap_or("");
let query = querystring::querify(&query_str);
let mut init_count: Option<u32> = None;
for (k, v) in query {
if k.eq("init") {
match v.parse::<u32>() {
Ok(v) => init_count = Some(v),
Err(_) => return Ok((response::bad_request(), None)),
}
}
}
let (res, binary) = if path.starts_with("/static") {
response::file(&path)
} else {
// render page
response::ssr(&path, init_count)
}
.unwrap_or_else(|_| response::internal_error());
Ok((res, binary))
}
The response.rs
program packs the response object for static assets and server-rendered content. For the latter, you could see that SSR happens at app.render().to_string()
, the result string is put into HTML by replacing the placeholder text.
use crate::mime::MimeType;
use anyhow::Result;
use parsed::http::{Header, Response};
use std::fs::{read};
use std::path::Path;
use isomorphic_app::App;
const HTML_PLACEHOLDER: &str = "#HTML_INSERTED_HERE_BY_SERVER#";
const STATE_PLACEHOLDER: &str = "#INITIAL_STATE_JSON#";
pub fn ssr(path: &str, init: Option<u32>) -> Result<(Response, Option<Vec<u8>>)> {
let html = format!("{}", include_str!("./index.html"));
let app = App::new(init.unwrap_or(1001), path.to_string());
let state = app.store.borrow();
let html = html.replace(HTML_PLACEHOLDER, &app.render().to_string());
let html = html.replace(STATE_PLACEHOLDER, &state.to_json());
Ok((Response {
protocol: "HTTP/1.0".to_string(),
code: 200,
message: "OK".to_string(),
headers: vec![
Header {
name: "content-type".to_string(),
value: MimeType::from_ext("html").get(),
},
Header {
name: "content-length".to_string(),
value: html.len().to_string(),
},
],
content: html.into_bytes(),
}, None))
}
/// Get raw file content
pub fn file(path: &str) -> Result<(Response, Option<Vec<u8>>)> {
let path = Path::new(&path);
if path.exists() {
let content_type: MimeType = match path.extension() {
Some(ext) => MimeType::from_ext(ext.to_str().get_or_insert("")),
None => MimeType::from_ext(""),
};
let content = read(path)?;
Ok((Response {
protocol: "HTTP/1.0".to_string(),
code: 200,
message: "OK".to_string(),
headers: vec![
Header {
name: "content-type".to_string(),
value: content_type.get(),
},
Header {
name: "content-length".to_string(),
value: content.len().to_string(),
},
],
content: vec![],
}, Some(content)))
} else {
Ok((Response {
protocol: "HTTP/1.0".to_string(),
code: 404,
message: "Not Found".to_string(),
headers: vec![],
content: vec![],
}, None))
}
}
/// Bad Request
pub fn bad_request() -> Response {
Response {
protocol: "HTTP/1.0".to_string(),
code: 400,
message: "Bad Request".to_string(),
headers: vec![],
content: vec![],
}
}
/// Internal Server Error
pub fn internal_error() -> (Response, Option<Vec<u8>>) {
(Response {
protocol: "HTTP/1.0".to_owned(),
code: 500,
message: "Internal Server Error".to_owned(),
headers: vec![],
content: vec![],
}, None)
}
The mime.rs
program is a map for assets' extension names and the Mime type.
pub struct MimeType {
pub r#type: String,
}
impl MimeType {
pub fn new(r#type: &str) -> Self {
MimeType {
r#type: r#type.to_string(),
}
}
pub fn from_ext(ext: &str) -> Self {
match ext {
"html" => MimeType::new("text/html"),
"css" => MimeType::new("text/css"),
"map" => MimeType::new("application/json"),
"js" => MimeType::new("application/javascript"),
"json" => MimeType::new("application/json"),
"svg" => MimeType::new("image/svg+xml"),
"wasm" => MimeType::new("application/wasm"),
_ => MimeType::new("text/plain"),
}
}
pub fn get(self) -> String {
self.r#type
}
}
That's it! Now let's build and run the web application. If you have tested the original example, you have already built the client WebAssembly.
cd client
./build-wasm.sh
Next, build and run the server.
cd ../server-wasmedge
cargo build --target wasm32-wasi
OUTPUT_CSS="$(pwd)/../client/build/app.css" wasmedge --dir /static:../client/build ../../../target/wasm32-wasi/debug/isomorphic-server-wasmedge.wasm
Navigate to http://127.0.0.1:3000
, and you will see the web application in action.
Furthermore, you can place all the steps into a shell script ../start-wasmedge.sh
.
#!/bin/bash
cd $(dirname $0)
cd ./client
./build-wasm.sh
cd ../server-wasmedge
OUTPUT_CSS="$(pwd)/../client/build/app.css" cargo run -p isomorphic-server-wasmedge
Add the following to the .cargo/config.toml
file.
[build]
target = "wasm32-wasi"
[target.wasm32-wasi]
runner = "wasmedge --dir /static:../client/build"
After that, a single CLI command ./start-wasmedge.sh
would perform all the tasks to build and run the web application!
We forked the Percy repository and made a ready-to-build server-wasmedge example project for you. Happy coding!