Learning WebAssembly #9: AssemblyScript Basics

AssemblyScript is a free and open source TypeScript-like language that gives developers low-level control over Wasm features.


We already met AssemblyScript in the previous part of this series. We created a simple program and got an impression of what programming in AssemblyScript feels like. In this part, we will go deeper into the concepts and basics of the language.

You can find the example source code on GitHub.

Getting Started

AssemblyScript compiles a strict variant of TypeScript to WebAssembly using Binaryen compiler and toolchain infrastructure library.

We will use npm to initialize our first AssemblyScript project:

$ npm init -y

To compile AssemblyScript into Wasm we need to install the asc compiler as a dev dependency:

$ npm install --save-dev assemblyscript

Once installed, the compiler provides a handy scaffolding utility to quickly set up a new project:

$ npx asinit .

We will be modifying two files from the generated project:

index.html
The main file for loading and working with Wasm modules.
assembly/index.ts
AssemblyScript source file to be compiled into Wasm.
tests/index.js
Test suite for the main file.

Exports and Imports

Let us create our first AssemblyScript function in assembly/index.ts:

export function main(): void {
  print(42);
}

Unfortunately, we don’t get the function print for free. Leaving WASI aside, interactions between Wasm modules and operating system must be provided by the glue code, JavaScript in our case. We must declare the function signature first and tell the compiler what function should be imported from the environment (console.log in this case):

@external("env", "console.log")
declare function print(n: i32): void;

Before we can run this code, first, we must compile our Wasm module:

$ npm run asbuild

This convenient script runs the asc compiler and generates untouched debug and optimized release Wasm files into the build directory.

Also the glue code is automatically generated by the compiler in build/debug.js and build/release.js.

We run the generated index.html in a web browser, or we can run this simple JavaScript file in Node.js:

// index.js
import { main } from "./build/release.js";
main();
$ node index.js
42

Eureka, we have just created our first working Wasm program in AssemblyScript!

Strings, Strings, Strings!

Don’t get too excited, but there is one thing I must share with you: AssemblyScript extends the set of Wasm standard data types for strings.

export function hello(name: string): string {
  return "Hello, " + name + "!";
}

We can import and export strings from and into JavaScript with a little help from the compiler. We have to instruct the compiler to export helper runtime functions by setting the exportRuntime to true in asconfig.json:

{
  ...
  "options": {
    ...
    "exportRuntime": true
  }
}

Amazingly enough, that's all we need to do to call the hello function:

import { hello } from "./build/release.js";
var greeting = hello("Joe");  // "Hello, Joe!"

Memory

Wasm uses linear memory stored in a specific memory offset isolated from other programs.

In AssemblyScript, there is static memory for static data known at the compilation time and dynamic memory (heap) managed by the runtime. Programs access chunks of memory via pointers. Dynamic memory is tracked by the runtime’s garbage collector and reused when not needed by the program anymore.

We have already seen allocating memory when working with strings. It was done by the glue code generated by the compiler. Now, let's do at the heavy lifting by ourselves to see what's going on under the hood.

First, we have to load the Wasm module to import its runtime functions and memory itself:

var url = "./build/release.wasm";
var module = await (
  typeof globalThis.fetch === "function"
    ? WebAssembly.compileStreaming(globalThis.fetch(url))
    : WebAssembly.compile((await import("node:fs")).readFileSync(url))
)
var imports = {
  env: {
    abort: m => { /* ... */ }
  }
}
const { exports: {
    hello, memory,
    __new, __pin, __unpin
}} = await WebAssembly.instantiate(module, imports);

Function __pin pins the object externally so it does not become garbage collected until unpinned again via __unpin. Function __new allocates a new object by its class ID and length. Let's see them in action:

// strings in AS are UTF-16 encoded
var input = "Україна💙💛";
var length = input.length;

// allocate memory (usize, string [class id=1])
var pt = __new(length << 1, 1);

// load bytes into memory (chars in AS are 16-bit)
var ibytes = new Uint16Array(memory.buffer);
for (let i = 0, p = pt >>> 1; i < length; ++i) 
ibytes[p + i] = input.charCodeAt(i);

// pin object 
var pti = __pin(pt);

// call wasm
var pto = __pin(hello(pti));

// retrieve string lenght in bytes (uint is 32-bit)
var SIZE_OFFSET = -4;
var olength = new Uint32Array(memory.buffer)[pto + SIZE_OFFSET >>> 2];

// load string from memory 
var obytes = new Uint16Array(
    memory.buffer, 
    pto, 
    olength >>> 1  // 8-bit length to 16-bit length
);
var str = utf16_to_string(obytes);

// unpin objects for GC
__unpin(pti);
__unpin(pto);

console.log(str);

As we can see, strings in AssemblyScript are nothing more than arrays of 16-bit unsigned integers stored in memory. The language brings some convenient syntax and utilities, however, internally, it does work with the same old Wasm mechanism.

The result should be no surprise:

$ node index.js
Hello, Україна💙💛!

Arrays

AssemblyScript provides convenient functions not only for strings, but we can also work with all kinds of arrays.

As an example, consider a function that takes an array of 32-bit integers and returns a new array where all elements are multiplied by a scalar. In AssemblyScript, it would look like this:

export function multiply(
    matrix: Int32Array, x: i32): Int32Array {

  var arr = new Int32Array(matrix.length);
  for (var i = 0; i < matrix.length; i++) {
    arr[i] = matrix[i] * x;
  }
  return arr;
}

Working with arrays is as easy as working with string thanks to the generated glue code:

import { multiply } from "./build/release.js";

var result = multiply([1, 2, 3], 2)

// Int32Array(3) [ 2, 4, 6 ]
console.log(result);

Testing AssemblyScript

After initializing a new AssemblyScript project, you can find a basic test in test/index.js. Basically, we can test everything which we export from the Wasm file:

// test/index.js

import assert from "assert";

import { hello } from "../build/debug.js";

assert.strictEqual(hello("Joe"), "Hello, Joe!");

console.log("ok");

Run it simply with npm:

$ npm test

Further Steps

We have learned the basics of programming in AssemblyScript, including utilities for working with memory, strings and arrays, and we have written our first unit test.

In the next part of this series we will go deeper into programming in AssemblyScript and learn how to generate some graphics.

Stay tuned!