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.


print("Hello from WebAssembly Lua!")

Setup

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

The Rust toolchain

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 = "2021"

[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
  • -sERROR_ON_UNDEFINED_SYMBOLS=0 (optional for binary crates): this ignores undefined symbols. Typically undefined symbols are not really undefined but the linker just can't find them, which is always the case if your crate is a library

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=-sERROR_ON_UNDEFINED_SYMBOLS=0");
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.

#[no_mangle]
pub 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.

#[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 = &mut *lua;
    // converting the c string into a `CStr` (which then can be converted to a `String`)
    let to_execute = 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

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.