jawsm

JavaScript to WASM compiler
GitHub
786
Created a month ago, last commit a day ago
Number of contributors not available
34 commits
16 added yesterday
README

Jawsm

Jawsm (pronounced like "awesome") is a JavaScript to WebAssembly compiler written in Rust. It is similar to porffor in a way it also results in a standalone WASM binary that can be executed without an interpreter, but it takes a different implementation approach.

It's an experimental tool and it's not ready for production. A lot of the language features and builtin types are missing or incomplete. That said, my goal is to eventually support 100% of the language.

Why Jawsm?

I started this project while working on a stress testing tool called Crows that runs WebAssembly scenarios. At the moment it only supports code compiled from Rust to WASM. As much as I love writing Rust, I also know it's not a widely popular language and besides, small tests are often easier to write in interpreted languages. The problem is, running scripting languages on top of WASM is not ideal at the moment. You have to either include an interpreter, which automatically makes the binary at least a few MBs in size and the memory usage even bigger, or use a variation of the language you're targetting (like TinyGo instead of Go, or AssemblyScript instead of TypeScript/JavaScript).

I believe that with modern WASM proposals it is possible to implement 100% of JavaScript features without the need to use a compiled interpreter, as WASM runtimes are already interpreters.

If you want to see it happen, please consider sponsoring my work

What works

As I eventually want to implment 100% of the language, I'm purposefully focused on implementing the semantics first, rather than go for 100% of builtins and grammar as I want to be 100% sure it's doable.

I have a list of 4 things that I think are hard to implement and after I implement all of them I will focus on more grammar and builtins. These are:

  1. Scopes/closures
  2. try/catch
  3. async/await
  4. generators

The last two are kind of similar as by getting generators working, one essentially has tools to make async await work, but I still wanted to make the distinction. At the moment Jawsm can compile code using closures with (mostly) proper scopes support, it allows try/catch and it implements (limited) Promise API and async (but not await yet). For example the following script will print error: foo:

let value = "foo";
async function foo() {
  throw value;
}

foo().then(
  function () {},
  function (v) {
    console.log("error", v);
  },
);

A non exhaustive list of other stuff that should work:

  • declaring and assigning: var, let, const
  • while
  • string lierals, adding string literals
  • numbers and basic operators (+, -, *, /)
  • booleans and basic boolean operators
  • array literals
  • object literals
  • new keyword

Host requirements

As Jawsm is built with a few relatively recent WASM proposals, the generated binaries are not really portable between runtimes yet. I'm aiming to implement it with WASIp2 in mind, but the only runtime capable of running components and WASIp2, ie. Wasmtime, does not support some other things I use, like parts of the WASM GC proposal or exception handling.

In order to make it easier to develop before the runtimes catch up with standardized proposals, I decided to use V8 (through Chromium or Node) with a Javascript polyfill for WASIp2 features that I need. There is a script run.js in the repo that allows to run binaries generated by Jawsm. Eventually it should be possible to run them on any runtime implementing WASM GC, exception handling and WASIp2 API.

How to use it?

Unless you want to contribute you probably shouldn't, but after cloning the repo you can use an execute.sh script like:

./execute.sh --cargo-run path/to/script.js

It will generate a WAT file, compile it to a binary and then run using Node.js.

It requires Rust's cargo, relatively new version of wasm-tools and Node.js v23.0.0 or newer. Passing --cargo-run will make the script use cargo run command to first compile and then run the project, otherwise it will try to run the release build (so you have to run cargo build --release prior to running ./execute.sh without --cargo-run option)

What's next?

My plan is to finish implementing all of the "hard to implement" features first, so next in line are generators and await keyword support. Ideally I would use the stack-switching proposal for both await and generators, but alas it's only in Phase 2 and it has minimal runtime support (I could find some mentions in Chromium development groups, but I couldn't get it to work). In the absence of stack-switching I'm working on using CPS transforms in order to simulate continuations.

After that's done, I will be slowly implementing all of the missing pieces, starting with grammar (for loops, switch etc) and then builtin types and APIs.

How does it work?

The project is essentially translating JavaScript syntax into WASM instructions, leveraging instructions added by WASM GC, exception handling and tail call optimizations proposals. On top of the Rust code that is translating JavaScript code, there is about 3k lines of WAT code with all the plumbing needed to translate JavaScript semantics into WASM.

To give an example let's consider scopes and closures. WASM has support for passing function references and for structs and arrays, but it doesn't have the scopes semantics that JavaScript has. Thus, we need to simulate how scopes work, by adding some extra WASM code. Imagine the following JavaScript code:

let a = "foo";

function bar() {
  console.log(a);
}

bar();

In JavaScript, because a function definition inherits the scope in which it's defined, the bar() function has access to the a variable. Thus, this script should print out the string "foo". We could translate it to roughly the following pseudo code:

// first we create a global scope, that has no parents
let scope = newScope(null);

// then we set the variable `a` on the scope
declareVariable(scope, "a", "foo");

// now we define the  bar function saving a reference to the function
let func = function(parentScope: Scope, arguments: JSArguments, this: Any) -> Any {
  // inside a function declaration we start a new scope, but keeping
  // a reference to the parentScope
  let scope = newScope(parentScope);

  // now we translate console.log call retreiving the variable from the scope
  // this will search for the `a` variable on the current scope and all of the
  // parent scopes
  console.log(retrieve(scope, "a"));
}
// when running a function we have to consider the scope
// in which it was defined
let fObject = createFunctionObject(func, scope);
// and now we also set `bar` on the current scope
declareVariable(scope, "bar", fObject)

// now we need to fetch the `bar` function from the scop
// and run it
let f = retrieve(scope, "bar");
call(f);

All of the helpers needed to make it work are hand written in WAT format. I have some ideas on how to make it more efficient, but before I can validate all the major features I didn't want to invest too much time into side quests. Writing WAT by hand is not that hard, too, especially when you consider WASM GC.

Sponsors

While working on this project I have received support on GitHub Sponsors by the following people. Thank you for all the support!

License

The code is licensed under Apache 2.0 license