VueJS + Rust + WebAssembly + WebWorker hosted on AWS S3, an example

Julien de Charentenay
9 min readJun 19, 2021

The views/opinions expressed in this story are my own. This story relates my personal experience and choices, and is provided for information in the hope that it will be useful but without any warranty.

This story describes my setup for a VueJS website, created using the Vue CLI, to run, via WebWorkers, WebAssembly compiled from Rust . The VueJS website is deployed and served from an AWS S3 bucket.

I have hit my month allowance of buzzwords by now...

This story is looking at how these technologies are put together to serve the fluid dynamics simulation engine presented here and employed to compare native and WebAssembly speed here. It will be relevant to you if you are thinking or trying to do something similar and are having some issues along the way.

Image by Markus Winkler from Pixabay

Background

The objective of the setup is to serve a fluid dynamics simulation engine from a webpage for it to be run within the web-browser. The fluid dynamics simulation engine is written in Rust and compiled in WebAssembly to maximise its execution speed compared to writing it directly in JavaScript — in theory as no JavaScript solver is available to compare. As the simulation engine requires processing power, it is run in a separate WebWorker to prevent making the webpage unresponsive.

The following diagram presents the different components of the website:

Rust

The Rust and WebAssembly combination is well described in the Rust and WebAssembly book.

The prerequisites, refer to the Setup page, are:

  • The standard Rust toolchain — downloaded from rust-lang.org. I used the recommended approach: rustup;
  • wasm-pack — downloaded from rustwasm.github.io;
  • I installed the wasm32-unknown-unknown compilation target using the following command — but am unsure whether this step is required:
rustup target add wasm32-unknown-unknown

I did not install the cargo-generate utility as I am aiming of adding WebAssembly support to an existing VueJS project. npm is already available on my system as it is required for other projects and VueCLI.

The project — available on github — root level contain a rust directory with the Rust part of the project and a www directory with the VueJS/VueCLI part of the project.

The rust directory is the root of the project’s Rust Workspace. The Workspace contains three crates:

  • vortex-particle-simulation is a library crate containing the algorithm for the fluid dynamics simulation engine based on the Vortex Particle methods;
  • cli is a binary crate that provides a command line interface for the library;
  • wasm is the library crate that defines the interface for the WebAssembly module.

The wasm crate contains the following key components. The file src/lib.rs defines a global variable SIMULATION that is used to store the Simulation object between calls to the functions defined in the library. The library provides a simple API with three functions:

#[wasm_bindgen]
pub fn make_from_configuration(content: &str) -> Result<(), JsValue>
#[wasm_bindgen]
pub fn step(time_step: f64) -> Result<(), JsValue>
#[wasm_bindgen]
pub fn iteration() -> JsValue

Each API function is declared using #[wasm_bindgen] so that they can be called from JavaScript.

The vortex-particle-simulation library crate includes a profiler function to identify the long lasting calculations. The Rust SystemTime struct is not supported in WebAssembly. As an workaround, the wasm library declares an external function time_now_ms as described here and shown below. The function is defined in a local JavaScript file functions.js located in the wasm library crate root directory. More information and details on the inclusion of JavaScript with Rust is described here.

#[wasm_bindgen(module = "/functions.js")]
extern "C" {
fn time_now_ms() -> f64;
}

Lastly, the wasm library crate is configured to include the wasm-bindgen crate and declare the crate type — similarly to the wasm-bindgen Hello World! tutorial. It also declares the vortex-particle-simulation library crate as a dependency as shown below:

[package]
name = "wasm"
version = "0.1.0"
authors = ["Julien de Charentenay <julien@charentenay.me>"]
edition = "2018"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
serde = { version = "1.0", features = ["derive"] }
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["console"] }
vortex-particle-simulation = { path = "../vortex-particle-simulation" }

The vortex-partcle-simulation library crate of the Workspace had to be modified to remove calls to std::time::SystemTime as these were not supported in WebAssembly. However it is not modified to add parameters or flags particular to WebAssembly.

As a side note, I tried to use the serde-wasm-bindgen crate in the wasm crate without success and reverted to passing a JSON string as an argument to make_from_configuration and converting the JSON string back to an object using serde.

VueJS/VueCLI

The VueJS website is created using VueCLI, i.e vue create www with the following configuration options:

  • Vue 2;
  • Babel;
  • Vuex to provide a store available throughout the webpage;
  • Linter/formated using eslint and prettier option;
  • Testing based on unit testing using Jest and end-to-end testing using cypress;
  • The configurations are placed in separate config files.

The above provide the default configuration for the webpage. It is extended to use tailwind CSS, and allow for end-to-end testing code coverage using Istanbul.

The following changes were made to integrate WebAssembly with the VueJS website:

  • Wasm Pack Plugin: Requires wasm-pack and is installed as a development dependency using:
npm install --save-dev @wasm-tool/wasm-pack-plugin
  • npm will compile and install the WebAssembly module from Rust automatically using Webpack — which is used by VueJS under-the-hood. The Webpack configuration is provided in vue.config.js in place of webpack.config.js as shown below. The configuration is tailored to the project setup as follows: (a)crateDirectory nominates the path to the root directory of the crate containing the wasm-bindgen library, (b) outDir nominates the location of the output directory. It is set to a pkg subdirectory in the project src directory. This allows the initialization of the WebAssembly library in JavaScript to be based on the VueCLI directory structure, namely using import("@/pkg") . And (c) the configuration is set to always compile WebAssembly using a production/optimised built using the parameter forceMode.
const WasmPackPlugin = require("@wasm-tool/wasm-pack-plugin");
const webpack = require("webpack");
module.exports = {
[...],
chainWebpack: (config) => {
// rust wasm bindgen https://github.com/rustwasm/wasm-bindgen
config
.plugin("wasm-pack")
.use(WasmPackPlugin)
.init(
(Plugin) =>
new Plugin({
crateDirectory: path.resolve(__dirname, "../rust/wasm"),
outDir: path.resolve(__dirname, "./src/pkg"),
// forceMode: "development",
forceMode: "production",
})
)
.end()
// needed for Edge browser https://rustwasm.github.io/docs/wasm-bindgen/examples/hello-world.html
.plugin("text-encoder")
.use(webpack.ProvidePlugin)
.init(
(Plugin) =>
new Plugin({
TextDecoder: ["text-encoding", "TextDecoder"],
TextEncoder: ["text-encoding", "TextEncoder"],
})
)
.end();
},
};

At this point, Rust is automatically compiled to WebAssembly by npm when running the development server using npm run serve or building the production deployment using npm build.

However, npm run serve fails with the following error due to the lint setting selected when creating the VueJS project. The file pkg\index.js is automatically generated by wasm-pack and hence can not be modified to fix the error.

[...]\vortexParticleSimulation\www\src\pkg\index.js
1:13 error 'wasm' is defined but never used no-unused-vars
2:31 warning Insert `⏎` prettier/prettier
✖ 2 problems (1 error, 1 warning)
0 errors and 1 warning potentially fixable with the `--fix` option.

Two options are available: (a) overwrite the no-unused-varslint rule in .eslintrc.js or (b) ignore the src\pkg folder during linting. The latter is achieved by adding the following to .eslintignore in the www directory:

# Ignore wasm folder/file generated by wasm-pack
src/pkg

The WebAssembly module is now accessible from the VueJS project. The functions declared in the WebAssembly module are loaded and executed using a two step process: (1) the WebAssembly module is loaded using an import statement, and (2) the WebAssembly functions can be called from the loaded module as shown below:

let wasm = import("@/pkg");
wasm.then((w) => {
w.make_from_configuration(JSON.stringify(evt.data.make));
});

The fluid simulation engine is a resource intensive task. When running in the main thread, the webpage becomes unresponsive, hence the use of WebWorker is required.

WebWorker

The WebWorker is managed using the worker-loader npm module for webpack. The module limits the boiler plate code required for defining and using WebWorkers. The location of the WebWorkers is defined in vue.config.js and set to the files with a file name finishing with .worker.js . In the implementation, these files are kept to a directory called workers for ease of reference.

config.module.rule("js").exclude.add(/\.worker\.js$/);
config.module
.rule("worker")
.test(/\.worker\.js$/)
.use("worker-loader")
.loader("worker-loader")
.end();

The Solver.vue VueJS component manages the solver and its associated WebWorker, which is implemented in workers\SolverWebAssembly.worker.js . It uses JavaScript class called SolverWebassembly to wrap the WebWorkers functionalities into class method calls to faciliate future troubleshooting if required.

The WebWorker is defined in the Solver.vue component by declaring the WebWorker as follows — thanks to the worker-loader module:

import WorkerSolverWebassembly from "@/workers/SolverWebassembly.worker.js";

And then instancing an instance of the class, if WebWorkers are available:

if (window.Worker) {
this.#worker = new WorkerSolverWebassembly();
this.#worker.onmessage = onmessage;
this.#worker.onerror = onerror;
} else {
throw "WebWorkers are not supported by this web browser. Unable to proceed with solving";
}

The WebWorker functionalities are defined in SolverWebassembly.worker.js which is divided into 3 sections: (a) variables to store information between calls to the WebWorker, (b) functions that handles command and (c) parsing of incoming messages from the main thread. The WebAssembly module is initialised in the WebWorker function handle_make and stored in the variable wasm. Two functionalities are currently implemented and divided in two separate handling functions: handle_start and handle_stop that starts and stops the simulation following on user action.

/*
* WebWorker to handle the running of the solver in webassembly
*/
{
// (a) Definition of variables
let wasm = null;
let initializing = false;
let initialized = false;
let starting = false;
let interval = null;
let timeout = null;
let start_performance = null;
// (b) Handling function declaration
let handle_make = (evt) => {
initializing = true;
wasm = import("@/pkg");
wasm.then((w) => {
w.make_from_configuration(JSON.stringify(evt.data.make));
initializing = false;
initialized = true;
});
};
let handle_start = (evt) => {
if (initializing) {
timeout = setTimeout(() => {
timeout = null;
handle_start(evt);
}, 500);
} else if (initialized) {
starting = true;
start_performance = performance.now();
wasm.then((w) => {
starting = false;
interval = setInterval(() => {
w.step(evt.data.start);
}, 0);
});
} else {
throw "Start is called before the solver is initialized";
}
};
let handle_stop = (evt) => {
if (starting) {
setTimeout(() => {
handle_stop(evt);
}, 500);
}
if (interval) {
clearInterval(interval);
interval = null;
console.log("Analysis runtime: " + (performance.now() - start_performance) + "ms");
} else if (timeout) {
clearTimeout(timeout);
timeout = null;
} else {
throw "Stop is called before the solver is initialized";
}
};
// (c) Message handling
onmessage = (evt) => {
if (evt.data instanceof Object) {
// console.log("SolverWebassembly: Received event: ", evt.data);
if (evt.data.make) {
handle_make(evt);
} else if (evt.data.start) {
handle_start(evt);
} else if (evt.data.stop) {
handle_stop(evt);
} else {
throw "Event is not recognised " + JSON.stringify(evt.data);
}
}
};
}

Deploying to AWS

The webpage is packaged for deployment using the npm command npm build that generates, in the dist folder the required html, js and wasm files to be deployed on the webserver.

AWS S3 can be used to host the webpage, preferably in conjunction with AWS CloudFront for content delivery and AWS route 53 for DNS routing. The deployment is as simple as uploading all the files located in the dist folder to the S3 bucket. However, the wasm file content type is set to application\octet-stream during the upload to AWS S3 resulting in the following browser console error when loading the webpage:

Uncaught (in promise) TypeError: Failed to execute ‘compile’ on ‘WebAssembly’: Incorrect response MIME type. Expected ‘application/wasm’.

This issue is already reported and can be fixed by changing the wasm file content-type to application\wasm . This can be done manually in the AWS S3 console. It may also be possible to automate this process in a deployment pipeline using the AWS CLI as described here.

Parting Word

This story aims to provide a single place to record the process used to package a WebAssembly module running within a WebWorker on a VueJS webpage that is hosted on AWS. It is written for other to take inspiration from as well as to provide me with a record of the process.

It was written after the work was completed. Whilst I tried to make it accurate, it is possible that it is missing some elements. If you identify any missing elements or incorrect steps, please get in touch to allow additions or corrections to be made.

--

--

Julien de Charentenay

I write about a story a month on rust, JS or CFD. Email masking side project @ https://1-ml.com & personal website @ https://www.charentenay.me/