VueJS + Rust + WebAssembly + WebWorker hosted on AWS S3, an example
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.
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 invue.config.js
in place ofwebpack.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 thewasm-bindgen
library, (b)outDir
nominates the location of the output directory. It is set to apkg
subdirectory in the projectsrc
directory. This allows the initialization of the WebAssembly library in JavaScript to be based on the VueCLI directory structure, namely usingimport("@/pkg")
. And (c) the configuration is set to always compile WebAssembly using a production/optimised built using the parameterforceMode
.
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-vars
lint 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.