Skip to content

drogus/jawsm

Repository files navigation

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 implement 100% of the language, I'm purposefully focused on implementing the parts of the language that are the hardest to implement rather than go for a large number of simpler features.

When I started the project I listed four features of the language that I think are crucial to implement in order to test the viability of the project

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

Since then I've been able to implement all four of them with an exception of a combination of async and generators, ie. async generators, but support for async generators should be coming soon.

A non exhaustive list of other stuff that should work:

  • declaring and assigning: var, let, const
  • all of the loops: do..while, while, for, for..in, for..of
  • switch statement
  • limited support for break and continue (it works, but may be buggy)
  • string lierals, adding string literals
  • numbers and basic operators (+, -, *, /)
  • booleans and basic boolean operators
  • array literals
  • object literals
  • new keyword
  • async and await
  • limited support from Promise APIs
  • generator functions
  • try/catch

A few notable things that are missing at the moment:

  • async generators
  • most of the builtins and most of the methods on existing builtins
  • RegExp expressions
  • BigInt literals

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 (or by using a WASIp2 pollyfill).

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?

The current plan is to implement the following, in roughly the given order:

  • async generators
  • Basic regexp support (ie. RegExp literals and some very basic functions on the RegExp object)
  • BigInt literals and basic support for BigInts
  • Better support for automatic casting, for example when checking equality or using various operators
  • More functions at basic builtins: arrays, strings etc.

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