Lua in the Browser, with Rust 🦀 and WebAssembly

This smol book describes how to use Lua in the Browser, powered by Rust WebAssembly.

You should have basic knowledge of Rust, Rust FFI and Javascript, the book will not explain language features or constructs that are irrelevant to Rust WebAssembly.


-- Hello world example that executes right in your browser.
-- This is an interactive REPL, you can write any Lua code you want here.

print("Hello from WebAssembly Lua!")

How it works

Unlike other Lua VMs in the browser, like fengari that is completely written in JavaScript, the approach described here uses the official Lua VM to execute code. To interact with the Lua VM, the mlua crate is used which contains all relevant methods to interact with the VM.

Because Lua relies on some libc functions that aren't available in WebAssembly (most notably setjmp, which is used for error handling), it can't be built with the wasm32-unknown-unknown toolchain. This limitation is bypassed by using the wasm32-unknown-emscripten toolchain instead.

Why?

In most cases using "web-native" VMs like fengari is probably the better choice, especially if you just want your code to be run in the browser. But if you have an existing Rust codebase that uses Lua or that is planned to use Lua, and want to run it in the browser, the describe approach might be the right choice.

I personally had the case where user code got executed on a server, and I wanted the users to check their code for correctness in the browser before uploading/submitting it. The Lua environment had custom defined Rust functions that got exposed to Lua and any user code could use, and I didn't want to rewrite everything in JavaScript.

Setup

Before we can start developing, a few prerequisites must be fulfilled.

The Rust toolchain

Because Lua relies on some libc functions that aren't available in bare-bones WebAssembly (aka wasm32-unknown-unknown), the wasm32-unknown-emscripten toolchain is used, which provides a custom libc implementation. The downside of this toolchain is the compatability with the existing Rust WebAssembly ecosystem. Some crates that state to have WebAssembly support, are only supporting wasm32-unknown-unknown which might lead to some compatability problems.

To add the toolchain via rustup, use:

rustup target add wasm32-unknown-emscripten

The Emscripten compiler

To build for the wasm32-unknown-emscripten target, you need the emscripten compiler toolchain.

General install instructions are available here or you look if your package manager has an emscripten package (some examples provided below).

Debian

sudo apt install emscripten

Arch Linux

sudo pacman -S emscripten

# arch does not add the path to the emscripten executables to PATH, so it must be 
# explicitly added.
# you probably want to add this to your bashrc (or any other file which permanently 
# adds this to PATH) to make it permanently available
export PATH=$PATH:/usr/lib/emscripten

Tutorial

This tutorial covers how to set up a simple project, adding logic to it and calling it from a Javascript (browser) application.

We will build a simple wasm binary which is able to execute arbitrary Lua input (a repl, basically).

What will be covered?

  • How to set up a project (it's a bit more than just cargo init)
  • Calling the created wasm file from the browser

Creating a project

Create the project package

First, you need to create a normal Rust package which can either be a binary or library crate. A binary crate has a main function that will be executed when initializing the main function, a library crate needs a few more additional compiler flags to compile successfully.

As binary:

cargo init --bin my-package .

As library:

cargo init --lib my-package .

Configure files

Before you can start writing actual code you have to set up some files in the newly created library directory.

Cargo.toml

The mlua dependency is the actual lua library which we'll use. At least version 0.9.3 is required, as this is the first version which supports wasm. The features lua51, lua52, lua53, lua54 and luau are wasm compatible lua version. The vendored feature is always required for wasm.

[package]
name = "my-project"
version = "0.1.0"
edition = "2024"

[dependencies]
mlua = { version = ">=0.9.3", features = ["lua51", "vendored"] }

If your crate is a library, you have to additionally add this:

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

This must be done because the emscripten compiler expects the package to behave like a normal C shared library.

build.rs

You need to set some additional compiler flags to be able to call your wasm code from Javascript:

  • -sEXPORTED_RUNTIME_METHODS=['cwrap','ccall']: this exports the cwrap and ccall Javascript functions which allows us to call our library methods
  • -sEXPORT_ES6=1: this makes the created js glue ES6 compatible. It is not mandatory in general but needed as this tutorial/examples utilizes ES6 imports

If your package is a library, you have to add some additional options:

  • --no-entry: this defines that the compiled wasm has no main function
  • -o<library name>.js: by default, only a .wasm file is created, but some js glue is needed to call the built wasm file (and the wasm file needs some functions of the glue too). This creates the glue <library name>.js file and changes the name of the wasm output file to <library name>.wasm. This must be removed when running tests because it changes the output filename and the Rust test suite can't track this.

The best way to do this is by specifying the args in a build.rs file which guarantees that they are set when compiling:

fn main() {
    println!("cargo:rustc-link-arg=-sEXPORTED_RUNTIME_METHODS=['cwrap','ccall']");
    println!("cargo:rustc-link-arg=-sEXPORT_ES6=1");
}

If your package is a library, add the additionally required options to your build.rs:

let out_dir = std::env::var("OUT_DIR").unwrap();
let pkg_name = std::env::var("CARGO_PKG_NAME").unwrap();

// the output files should be placed in the "root" build directory (e.g. 
// target/wasm32-unknown-emscripten/debug) but there is no env variable which 
// provides this path, so it must be extracted this way
let target_path = std::path::PathBuf::from(out_dir)
    .parent()
    .unwrap()
    .parent()
    .unwrap()
    .parent()
    .unwrap()
    .join(pkg_name);

println!("cargo:rustc-link-arg=--no-entry");
println!("cargo:rustc-link-arg=-o{}.js", target_path.to_string_lossy());

.cargo/config.toml (optional)

Here you can set the default target to wasm32-unknown-emscripten, so you don't have to specify the --target wasm32-unknown-emscripten flag everytime you want to compile your project.
You can also set the default runner binary here which is useful when running tests, as Rust tries to execute the generated js glue directly which obviously doesn't work because a Javascript file is not an executable.

[build]
target = "wasm32-unknown-emscripten"

[target.wasm32-unknown-emscripten]
runner = "node --experimental-default-type=module"

Adding wasm logic

Adding logic on the wasm / Rust side is very much just like writing a (C compatible) shared library.

Let's begin simple. This function creates a Lua instance and returns the raw pointer to it.

#[unsafe(no_mangle)]
pub unsafe extern "C" fn lua_new() -> *mut mlua::Lua {
    let lua = mlua::Lua::new();
    Box::into_raw(Box::new(lua))
}

Alright, good. Now we have a Lua instance, but no way to use it, so let us create one.
The function takes the pointer to the Lua struct we create in the new_lua function, as well as an arbitrary string, which should be lua code, as parameters. It then executes this string via the Lua instance and may write to stderr if an error occurs.

#[unsafe(no_mangle)]
pub unsafe extern "C" fn lua_execute(lua: *mut mlua::Lua, to_execute: *const std::ffi::c_char) {
    // casting the raw pointer of the created lua instance back to a usable Rust struct
    let lua: &mut mlua::Lua = unsafe { &mut *lua };
    // converting the c string into a `CStr` (which then can be converted to a `String`)
    let to_execute = unsafe { std::ffi::CStr::from_ptr(to_execute) };
    
    // execute the input code via the lua interpreter
    if let Err(err) = lua.load(&to_execute.to_string_lossy().to_string()).exec() {
        // because emscripten wraps stderr, we are able to catch the error on the js
        // side just fine
        eprintln!("{}", err)
    }
}

Okay, this looks great! In theory. So let's head over to the next page to see how to compile the code to make it actually usable via Javascript.

Compiling

Before we can use our Rust code, we have to compile it first.

# you can omit '--target wasm32-unknown-emscripten' if you added the .cargo/config.toml
# file as describe in the "Setup" section
cargo build --target wasm32-unknown-emscripten

Calling from Javascript

The following code examples are expecting that the compiled glue and wasm files are available as target/wasm32-unknown-emscripten/debug/my-project.js and target/wasm32-unknown-emscripten/debug/my-project.wasm.

Browser

Note that opening the .html file as normal file in your browser will prevent the wasm from loading. You have to serve it with a webserver. python3 -m http.server is a good tool for this.

The following html page will be used as reference in the Javascript code.

<!DOCTYPE html>
<html>
  <head>
    <title>My Project</title>
  </head>
  <body>
    <div>
      <h3>Code</h3>
      <textarea id="code"></textarea>
      <button>Execute</button>
    </div>
    <div style="display: flex">
      <div>
          <h3>Stderr</h3>
          <div id="stderr" />
      </div>
      <hr>
      <div>
          <h3>Stdout</h3>
          <div id="stdout" />
      </div>
    </div>
  </body>
</html>

First things first, we need to load the compiled wasm file. For this, we import the Javascript glue that is generated when compiling and loads and configures the actual wasm file. A custom configuration is fully optional, but needed if you want to do things like catching stdio. The configuration is done via the Module object.

// importing the glue
const wasm = await import('./target/wasm32-unknown-emscripten/debug/my-project.js');
// creating a custom configuration. `print` is equal to stdout, `printErr` is equal to
// stderr
const module = {
    print(str) {
        const stdout = document.getElementById('stdout');
        const line = document.createElement('p');
        line.innerText = str;
        stdout.appendChild(line);
    },
    printErr(str) {
        const stderr = document.getElementById('stderr');
        const line = document.createElement('p');
        line.innerText = str;
        stderr.appendChild(line);
    }
};
// this loads the wasm file and exposes the `ccall` and `cwrap` functions which we'll
// use in the following code
const myProject = await wasm.default(module);

With the library loaded, it's time to call our first function, lua_new. This is done via the emscripten ccall function. It takes the function name we want to execute, its return type, the function parameter types and the parameters as arguments.
This will return the raw pointer (as js number) to the address where the Lua struct, we created in the Rust code, resides.

const luaInstance = myProject.ccall('lua_new', 'number', [], []);

Next up, lets make the lua_execute function callable. This time we're using the emscripten cwrap function. It wraps a normal Javascript function around the ffi call to the wasm lua_execute function, which is the recommended way to handle functions which are invoked multiple times. It takes the function name we want to execute, its return type and the function parameters as arguments.

const luaExecute = myProject.cwrap('lua_execute', null, ['number', 'string']);

With this all set up, we are able to call any Lua code via WebAssembly, right in the browser. Great!

luaExecute(luaInstance, 'print("Hello Lua Wasm")');
Full example as html page with Javascript
<!DOCTYPE html>
<html>
  <head>
    <title>My Project</title>
    <script type="module">
      const wasm = await import('./target/wasm32-unknown-emscripten/debug/my-project.js');
      const stdout = document.getElementById('stdout');
      const stderr = document.getElementById('stderr');
      const module = {
          print(str) {
              const line = document.createElement('p');
              line.innerText = str;
              stdout.appendChild(line);
          },
          printErr(str) {
              const line = document.createElement('p');
              line.innerText = str;
              stderr.appendChild(line);
          }
      };
      const myProject = await wasm.default(module);

      const luaInstance = myProject.ccall('lua_new', 'number', [], []);
      const luaExecute = myProject.cwrap('lua_execute', null, ['number', 'string']);
 
      window.execute = () => {
        // clear the output
        stdout.innerHTML = '';
        stderr.innerHTML = '';
        const code = document.getElementById('code').value;
        luaExecute(luaInstance, code);
      }
  </script>
</head>
<body>
  <div>
    <textarea id="code"></textarea>
    <button onclick="execute()">Execute</button>
  </div>
  <div style="display: flex">
    <div>
        <h3>Stderr</h3>
        <div id="stderr" />
    </div>
    <hr>
    <div>
        <h3>Stdout</h3>
        <div id="stdout" />
    </div>
  </div>
</body>
</html>

NodeJS

The nodejs implementation is not very different from the browser implementation, so the actions done aren't as detailed described as above. Please read the Browser section first if you want more detailed information.

class MyProject {
	#instance;
	#luaExecute;
	#stdout;
	#stderr;
	
	static async init() {
		const myProject = new MyProject();
		
		const wasm = await import('./target/wasm32-unknown-emscripten/debug/my-project.js');
		const module = {
			print(str) {
				if (myProject.#stdout) myProject.#stdout(str);
            },
            printErr(str) {
                if (myProject.#stderr) myProject.#stderr(str);
            }
        };
		const lib = await wasm.default(module);

        myProject.#instance = lib.ccall('lua_new', 'number', [], []);
        myProject.#luaExecute = lib.cwrap('lua_execute', null, ['number', 'string']);
		
		return myProject;
    }
		
    execute(code, stdout, stderr) {
		if (stdout) this.#stdout = stdout;
		if (stderr) this.#stderr = stderr;
		
		this.#luaExecute(this.#instance, code);

        if (stdout) this.#stdout = null;
        if (stderr) this.#stderr = null;
    }
}

Testing

Testing is not very different from testing any other ordinary Rust crate.

When running tests, Rust tries to execute the generated Javascript glue directly which will result in an error. You have to specify the test runner which executes the Javascript, either in the .cargo/config.toml file (described here) or via the CARGO_TARGET_WASM32_UNKNOWN_EMSCRIPTEN_RUNNER env variable to node --experimental-default-type=module.
If your crate is a library, you also have to remove the -o<library name>.js compiler option as it modifies the output filename which the Rust test suite can't track. Because the test subcommand compiles the tests as normal binaries, the Emscripten compiler automatically creates the js glue.

Before Rust 1.75 there were a major incompatibility between emscripten and the internal Rust libc crate (rust-lang/rust#116655) which always resulted in a compiler error. To prevent this issue, you have to set the -sERROR_ON_UNDEFINED_SYMBOLS=0 compiler option.

With this done, we can create a simple test:

#[cfg(test)]
mod tests {
    #[test]
    fn lua_test() {
        let lua = mlua::Lua::new();
        lua.load("print(\"test\")").exec().unwrap();
    }
}

And then run it:

# you can omit '--target wasm32-unknown-emscripten' if you added the .cargo/config.toml
# file as describe in the "Setup" section
cargo test --target wasm32-unknown-emscripten

Examples

There are two example crates, a binary and a library one:

Each directory has a nodejs.js file that can be executed with nodejs and runs the compiled code.

Benchmarks

To get an overview how the performance of WebAssembly compares to other implementations, some benchmarks were done. The used benchmark code is from the Computer Language Benchmarks Game.

Note that these are only micro-benchmarks. They test one very specific implementation of something, whereas a real program would use multiple or complete other things that aren't tested here.

The benchmark sources can be found here.

Environment

Every benchmark runs 5 times and the final benchmark results are calculated from the average result of each run. All benchmarks are using NodeJS to execute them. NodeJS itself always has a performance/memory overhead as it needs to spin up a new V8 engine every time, which is the explanation why the base memory is so high.

Host & OS:

  • Raspberry Pi 4, 2GB RAM
  • Raspberry Pi OS Lite, 64 bit, 2024-11-19

Software:

  • Rust v1.85.0
  • NodeJS v23.7.0
  • Emscripten v4.0.4

Benchmarked Software:

  • WebAssembly (wasm); mlua v0.10.3
  • Fengari (fengari); fengari v0.1.4

Results

binary-trees

xsourcesecsmem(kb)cpu secs
1.000wasm #20.394635230.250
1.944fengari #20.766764010.516
binary-trees with default arguments

fasta

xsourcesecsmem(kb)cpu secs
1.000wasm #30.418658400.290
1.000wasm #20.418660040.290
3.464fengari #21.448924100.830
3.474fengari #31.452885310.816
fasta with default arguments

mandelbrot

xsourcesecsmem(kb)cpu secs
1.000wasm #30.366597290.282
1.000wasm0.366597940.282
1.011wasm #20.370596560.290
3.208fengari1.174735710.910
3.410fengari #31.248731950.986
3.552fengari #21.300724891.016
mandelbrot with default arguments

n-body

xsourcesecsmem(kb)cpu secs
1.000wasm0.380617050.270
1.011wasm #40.384625630.284
1.016wasm #20.386625520.276
2.832fengari #21.076746350.626
2.842fengari1.080754420.650
2.863fengari #41.088746270.626
n-body with default arguments

spectral-norm

xsourcesecsmem(kb)cpu secs
1.000wasm0.554595670.450
4.801fengari2.660756512.324
spectral-norm with default arguments