How I created a SSR React App using WebAssembly (Wasm)
This article shows how I extended on the basic create-react-app
to create my own Server-Side Rendered React App which runs using the WasmEdge runtime.
In this article I will demonstrate:
- creating a brand new React App
- converting the App to Server-side rendering
- running the server-side rendered App using WebAssembly (using the WasmEdge runtime to deploy the App)
- extending the basic App to make a blog App & run again
Please note: this article references this WasmEdge React SSR tutorial and this React tutorial.
1 Preparation
1.1 System requirements
First we update the system (Ubuntu 20)
cd ~
sudo apt-get update
sudo apt install build-essential
sudo apt install mlocate
sudo apt-get install npm
npm i svg-url-loader
1.2 Installing Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
1.3 Setting the WASI target
rustup target add wasm32-wasi
1.4 Install WasmEdge CLI
curl -sSf https://raw.githubusercontent.com/WasmEdge/WasmEdge/master/utils/install.sh | bash
source $HOME/.wasmedge/env
1.5 Install WasmEdge QuickJS
git clone https://github.com/second-state/wasmedge-quickjs.git
cd wasmedge-quickjs/
cargo build --target wasm32-wasi --release
The above build will create a new file called wasmedge_quickjs.wasm
, we will be using that file later on.
2 Create a new React App
cd ~
npx create-react-app my-new-app
cd my-new-app
2.1 Test
Before we went ahead and changed the React App to make it server-side rendered, we started the app in its original form (to make sure that it works out of the box).
npm start
In the standard React app, which is client-side rendered, we see that the root
div is completely blank (as shown in the HTML source below)
The next step is to modify this app so it can become server-side rendered.
3 Convert to server-side rendering
3.1 Manual SSR modifications
Create a new directory called server
mkdir server
Create a new file in that server directory called index.js
vi server/index.js
Populate the contents of the new index.js file with the following code.
import * as React from 'react';
import ReactDOMServer from 'react-dom/server';
import * as std from 'std';
import * as http from 'wasi_http';
import * as net from 'wasi_net';import App from '../src/App.js';async function handle_client(cs) {
print('open:', cs.peer());
let buffer = new http.Buffer();while (true) {
try {
let d = await cs.read();
if (d == undefined || d.byteLength <= 0) {
return;
}
buffer.append(d);
let req = buffer.parseRequest();
if (req instanceof http.WasiRequest) {
handle_req(cs, req);
break;
}
} catch (e) {
print(e);
}
}
print('end:', cs.peer());
}function enlargeArray(oldArr, newLength) {
let newArr = new Uint8Array(newLength);
oldArr && newArr.set(oldArr, 0);
return newArr;
}async function handle_req(s, req) {
print('uri:', req.uri)let resp = new http.WasiResponse();
let content = '';
if (req.uri == '/') {
const app = ReactDOMServer.renderToString(<App />);
content = std.loadFile('./build/index.html');
content = content.replace('<div id="root"></div>', `<div id="root">${app}</div>`);
} else {
let chunk = 1000; // Chunk size of each reading
let length = 0; // The whole length of the file
let byteArray = null; // File content as Uint8Array
// Read file into byteArray by chunk
let file = std.open('./build' + req.uri, 'r');
while (true) {
byteArray = enlargeArray(byteArray, length + chunk);
let readLen = file.read(byteArray.buffer, length, chunk);
length += readLen;
if (readLen < chunk) {
break;
}
}
content = byteArray.slice(0, length).buffer;
file.close();
}
let contentType = 'text/html; charset=utf-8';
if (req.uri.endsWith('.css')) {
contentType = 'text/css; charset=utf-8';
} else if (req.uri.endsWith('.js')) {
contentType = 'text/javascript; charset=utf-8';
} else if (req.uri.endsWith('.json')) {
contentType = 'text/json; charset=utf-8';
} else if (req.uri.endsWith('.ico')) {
contentType = 'image/vnd.microsoft.icon';
} else if (req.uri.endsWith('.png')) {
contentType = 'image/png';
}
resp.headers = {
'Content-Type': contentType
};let r = resp.encode(content);
s.write(r);
}async function server_start() {
print('listen 8002...');
try {
let s = new net.WasiTcpServer(8002);
for (var i = 0; ; i++) {
let cs = await s.accept();
handle_client(cs);
}
} catch (e) {
print(e);
}
}server_start();
renderToString
The renderToString
function allows an app to render a React element to its initial HTML as an HTML string. We are essentially using this method to generate HTML on the server and then send the markup down on the initial request. This allows for faster page loads and also allows search engines to crawl these pages for SEO purposes. If you remember from above the standard client-side rendered React app actually had no content in the HTML for a search engine’s crawler to index.
Rollup
I now go ahead and bundle everything up so that we can run the app.
I create a new file (called rollup.config.js) in the apps root directory i.e.
vi ~/my-new-app/rollup.config.js
Then fill this rollup.config.js file with the following content.
const {babel} = require('@rollup/plugin-babel');
const nodeResolve = require('@rollup/plugin-node-resolve');
const commonjs = require('@rollup/plugin-commonjs');
const replace = require('@rollup/plugin-replace');const globals = require('rollup-plugin-node-globals');
const plugin_async = require('rollup-plugin-async');
const css = require("rollup-plugin-import-css");
const svg = require('rollup-plugin-svg');const babelOptions = {
babelrc: false,
presets: [
'@babel/preset-react'
],
babelHelpers: 'bundled'
};module.exports = [
{
input: './server/index.js',
output: {
file: 'server-build/index.js',
format: 'esm',
},
external: [ 'std', 'wasi_net','wasi_http', 'stream', 'util'],
plugins: [
plugin_async(),
babel(babelOptions),
nodeResolve({preferBuiltins: true}),
commonjs({ignoreDynamicRequires: false}),
css(),
svg({base64: true}),
globals(),
replace({
preventAssignment: true,
'process.env.NODE_ENV': JSON.stringify('production'),
'process.env.NODE_DEBUG': JSON.stringify(''),
}),
],
},
];
Next, we add the following dev dependencies to the package.json file
"devDependencies": {
"@rollup/plugin-babel": "^5.3.0",
"@rollup/plugin-commonjs": "^21.0.1",
"@rollup/plugin-node-resolve": "^7.1.3",
"@rollup/plugin-replace": "^3.0.0",
"rollup": "^2.60.1",
"rollup-plugin-async": "^1.2.0",
"rollup-plugin-import-css": "^3.0.3",
"rollup-plugin-node-builtins": "^2.1.2",
"rollup-plugin-node-globals": "^1.4.0",
"rollup-plugin-svg": "^2.0.0"
}
In the same package.json file, we also need to add a couple of line to the scripts section. The lines are highlighted in bold below, for your convenience.
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"dev:build-server": "rollup -c rollup.config.js",
"dev:start-server": "wasmedge --dir .:. --dir ./internal:/home/tpmccallum/wasmedge-quickjs/internal wasmedge_quickjs.wasm ./server-build/index.js"
}
The format for the dev:start-server
section is shown in the image below. Note: The absolute path to where you cloned the wasmedge-quickjs repository is wrapped by ./internal
on the left and internal
on the right and the actual .wasm file name is alone (wrapped by a space on its left and a space on its right).
4 Build
Build the blog software
cd ~/my-new-app
npm install
npm run build
npm run dev:build-server
5 Run
We can now start the server using the following command
npm run dev:start-server
The above will result in the following output
$ npm run dev:start-server> my-new-app@0.1.0 dev:start-server
> wasmedge --dir .:. --dir ./internal:/home/tpmccallum/wasmedge-quickjs/internal wasmedge_quickjs.wasm ./server-build/index.jslisten 8002...
Server-side rendering
At this point we have the same React app as before, only now (instead of having a empty root div) the visible content is rendered on the server side and is immediately present in the client’s HTML source code.
We now go ahead and make a couple of really quick changes and then rebuild and start the server-side rendered app.
First, I opened the src/App.js
file and paste in the following content.
import logo from './logo.svg';
import './App.css';
import React from 'react';function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<h1>Server-side rendering</h1>
</header>
</div>
);
}export default App;
Above, you can see that we have created a heading called Server-side rendering. Now we re-build and re-start the app.
npm run dev:build-server
npm run dev:start-server
This time, when we check the HTML source, we can see that the root div is no longer empty. Instead our content i.e. the words “Server-side rendering” are visible in the source.
Creating a Server-side rendered Blog App
Next we can make various changes to the original application to extend it.
In this case, I will be turning this into a blog demo. For this task I found a great React App tutorial which builds a blog demo. The video can be seen on YouTube and the source code is available freely on GitHub.
I followed the tutorial series from lesson 1 to lesson 20 to produce the following blog demo.
As you can see, our blog demo is running as a server-side rendered App using the WasmEdge WebAssembly runtime (and hosted at localhost on port 8002).
If you would like any information about the WasmEdge runtime, please visit WasmEdge.org or the WasmEdge GitHub organization page.