Porting JavaScript (or TypeScript) to AssemblyScript

We recently announced that our serverless compute environment, Compute@Edge, would support AssemblyScript, and followed that up with an article (by yours truly) on how AssemblyScript could be a great entry point for JavaScript and TypeScript developers onto Compute@Edge and WebAssembly. Today, rather than talking about how AssemblyScript and JavaScript are closely related, I’d like to show you. As a senior software engineer on Compute@Edge, as well as a member of the AssemblyScript core team, I wanted to dive into the process of porting common JavaScript applications to AssemblyScript and the considerations that come along with it.

First, let’s figure out what we want to port. Recently, I built a quick Compute@Edge AssemblyScript markdown demo based off of the experimental as-bind Markdown Parser demo. I noticed a round trip of my demo took about ~25ms at my home office. I wanted to know how much of that time was spent actually running my application code relative to uploading the markdown and downloading the HTML. I also wanted to know how much time was spent actually parsing the markdown into HTML, relative to other parts of the application. If we were to build something like this as a Node.js application, we could write the following code:

const prettyMilliseconds = require("pretty-ms");

function getPrettyExecutionTime() {
  // Get our start time in milliseconds (Unix Timestamp)
  const start = Date.now();

  // Do a random amount of busy work
  let iterations = 100000 + Math.floor(Math.random() * 10000000);
  for (let i = 0; i < iterations; i++) {
    iterations -= 1;
  }

  // Get our start time in milliseconds (Unix Timestamp)
  const end = Date.now();

  // Log out the Execution time in a human readable format
let responseString = "";
responseString +=
  "Pretty Unix timestamp: " +
  prettyMilliseconds(start, { verbose: true }) +
  "\n";
responseString +=
  "Busy work execution time: " +
  prettyMilliseconds(end - start, {
    verbose: true,
    formatSubMilliseconds: true,
  });

return responseString;
}

console.log(getPrettyExecutionTime());

Looking at the Node.js code, we can see that it uses the JavaScript Date global to get a Unix Timestamp-like value (number of milliseconds elapsed since January 1, 1970 00:00:00 UTC) to mark the start and the end of the execution of our Node.js application. We then log these timestamps in a human-readable format using the dependency pretty-ms found on npm. We also log the difference between these two timestamps, which would then give us the total execution time.

Next, let’s say we wanted to port this functionality from our Node.js application to our AssemblyScript application. The process will normally look something like this:

  1. Determine if the JavaScript Globals (such as objects on window or global) in the source code can be replaced by the AssemblyScript standard library, WASI hostcalls, or third-party AssemblyScript libraries (usually found on npm).

  2. Determine if the imported JavaScript dependencies (and the dependencies’ dependencies) have equivalent AssemblyScript dependencies. If not, port the dependencies, see Step 1.

  3. Add AssemblyScript types to the JavaScript code (or replace the TypeScript types with AssemblyScript compatible types).

  4. Adjust and move around JavaScript code that could cause issues with AssemblyScript syntax or libraries that are still in development. For example as of end of 2020, in my opinion, the (last) two major pieces of development work for AssemblyScript compiler are Closures and Regex. Adjust the code that interacts with JavaScript Globals or dependencies to work with the new replacement APIs.

Let’s apply this process to our Node.js application. Following Step 1, we will notice our Node.js application uses Math and Date. In particular, we need to replace the functionality of Math.floor(), Math.random(), and Date.now(). Looking at the AssemblyScript standard library, we can see AssemblyScript offers its own global Math.floor, so we don’t have to change anything here. AssemblyScript also offers its own global Math.random(); however, it states in the usage notes that we must also add import “wasi”, as it uses the WASI bindings that Compute@Edge supports to generate random numbers. So this will only take one line of code to port. Lastly, we need to port Date.now(), which does cause a little bit of trouble. AssemblyScript does offer a global Date object; however, it works by importing the Date object from a JavaScript host. Compute@Edge does not import the JavaScript Date object into WebAssembly modules. We will want to look for WASI or third-party libraries to replace this. 

To do this, we can do a search for something like “assemblyscript wasi date” on Google, and we would come across as-wasi. as-wasi is “A high-level AssemblyScript layer for the WebAssembly System Interface (WASI).” In other words, it offers a nice high-level API of common System level functionality for AssemblyScript applications using the AssemblyScript WASI bindings. Looking at their reference documentation, we will find they offer their own Date.now(), which is a great replacement for our JavaScript Date.now(). With this, we have determined that we can bring over our application source code to Compute@Edge. However, we need to do this same process for our dependencies. Our Node.js application has a single dependency, which is the very popular, MIT-licensed, npm package pretty-ms, with over 1 million weekly downloads. Looking at the source code for pretty-ms, we notice it has one dependency parse-ms, and uses the globals Math and Number. If we follow Step 1 again, we can see the AssemblyScript standard library Math and Number drop right in place to the current source code. 

Before we go onto parse-ms, I would also like to point out some syntax things we will have to change. First, you will notice that the source code uses closures, indicated by the nested functions. AssemblyScript supports passing and nesting pure functions, but to avoid headaches, let’s plan to pull these out into their own functions. Another thing to note is that this code uses CommonJS syntax to require() and export modules. AssemblyScript follows the standard ES Module import/export syntax, similar to Typescript, so this is one other small syntax thing that will need to be changed. Next, let’s look at our last dependency, parse-ms.

Taking a quick look at the parse-ms source code, we can see that this dependency is very minimal. Again, all globals can be satisfied by the AssemblyScript standard library. There is a snippet on type checking to ensure the passed value to the export function is a number, but we can ignore that since the AssemblyScript compiler will handle that for us!

So we have determined that the JavaScript globals, and our dependencies can be ported. Thus, our Node.js application can be ported over. Hooray — let’s start writing some code! I’ll generate a new AssemblyScript Compute@Edge application, using the Fastly CLI and running fastly compute init. At the time of writing this, this will generate our AssemblyScript starter kit, with @fastly/as-compute 0.1.3. Then, I’ll then start porting over the code into my AssemblyScript application. These code snippets below are heavily commented to explain what we’re doing relative to the JavaScript (and relative TypeScript types), so let’s take a look at the resulting code. First, I’ll start with our deepest dependency, parse-ms (JavaScript source code), and create a assembly/parse-ms.ts:

// This file is a port of:
// https://github.com/sindresorhus/parse-ms/blob/326500f7395fba4f47e73e36e6e770ad47c358d2/index.js
// The file is commented respective to how this is ported.

// As of 2020, AssemblyScript closure support is still in progress, and only supports pure nested functions
// Thus, we will pull out the nested function into its own function.
// This isn't required, but can avoid headaches in the future.
// This function takes in a float, but rounds it to an integer.
// In Typescript, `f64` would have been `number` types, but we must be explicit with the type of number in AS
// Ported from: https://github.com/sindresorhus/parse-ms/blob/326500f7395fba4f47e73e36e6e770ad47c358d2/index.js#L7
function roundTowardsZero(valueToRound: f64): f64 {
  if (valueToRound > 0) {
    return Math.floor(valueToRound);
  } else {
    return Math.ceil(valueToRound);
  }
}

// Define a class that represents the returned object fom the exported function
// We are exporting this as well, that way the type can be used
// Also, the `f64` type in TypeScript would be `number`, but in AS we must be explicit with the number type
// Ported from: https://github.com/sindresorhus/parse-ms/blob/326500f7395fba4f47e73e36e6e770ad47c358d2/index.js#L9
export class ParsedMilliseconds {
  days: f64;
  hours: f64;
  minutes: f64;
  seconds: f64;
  milliseconds: f64;
  microseconds: f64;
  nanoseconds: f64;
}

// Export a function to parse our milliseconds into our return type.
// Also, the `f64` type in TypeScript would be `number`, but in AS we must be explicit with the number type.
// Ported from: https://github.com/sindresorhus/parse-ms/blob/master/index.js#L2
export function parseMilliseconds(milliseconds: f64): ParsedMilliseconds {

  // We don't need to do a type check here, since we are using a typed language!
  // Referring to: https://github.com/sindresorhus/parse-ms/blob/326500f7395fba4f47e73e36e6e770ad47c358d2/index.js#L3

  // We moved roundTowardsZero into its own function
  // Referring to: https://github.com/sindresorhus/parse-ms/blob/326500f7395fba4f47e73e36e6e770ad47c358d2/index.js#L7

  // AssemblyScript will construct an instance of our return type (e.g new ParsedMilliseconds())
  // Since the return object has all the same properties of our return type.
  // This is so that we can pass a float into our `roundTowardsZero` function to handle the special rounding.
  return {
    days: roundTowardsZero(milliseconds / 86400000),
    hours: roundTowardsZero(milliseconds / 3600000) % 24,
    minutes: roundTowardsZero(milliseconds / 60000) % 60,
    seconds: roundTowardsZero(milliseconds / 1000) % 60,
    milliseconds: roundTowardsZero(milliseconds) % 1000,
    microseconds: roundTowardsZero(milliseconds * 1000) % 1000,
    nanoseconds: roundTowardsZero(milliseconds * 1e6) % 1000,
  };
}

Now that we have parse-ms, we can port pretty-ms (JavaScript source code). pretty-ms has some options for controlling the number of decimal places. To do this, it depends on the JavaScript Number.prototype.toFixed. However AssemblyScript has a current issue for their stdlib. We could write an implementation for this example, but to keep things short, we will not include this functionality. So let’s create a assembly/pretty-ms.ts:

// This file is a port of:
// https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js
// The file is commented respective to how this is ported.

// Import our ported ported `parse-ms` module
// This ports the `require()` call:
// Ported From: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L2
import { parseMilliseconds } from "./parse-ms";

const SECOND_ROUNDING_EPSILON: f32 = 0.0000001;

// Define our options object that will be passed into our exported `prettyMilliseconds` function
// The options are from the documentation: https://github.com/sindresorhus/pretty-ms#options
// However, we removed the `DecimalDigits` options and `keepDecimalsOnWholeSeconds`, as Float.toFixed is in progress:
// https://github.com/AssemblyScript/assemblyscript/issues/1163
// In Typescript, `f64` and `i32` would have been `number` types, but we must be explicit with the type of number in AS
// Ported from: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L8
export class PrettyMillisecondsOptions {
  compact: boolean = false;
  unitCount: i32 = 0;
  verbose: boolean = false;
  separateMilliseconds: boolean = false;
  formatSubMilliseconds: boolean = false;
  colonNotation: boolean = false;
}

// This function takes in our word (which would be a string),
// and the count of that word (a float), to pluralize it.
// Also, the `i32` type in TypeScript would be `number`, but in AS, we must be explicit with the number type.
// Ported from: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L4
function pluralize(word: string, count: f64): string {

  // Since AssemblyScript is type checked, there is no need for ===
  // We can use the standard ==
  // Referring to: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L4
  if (count == 1) {
    return word;
  }

  return word + "s";
}

// As of 2020, AssemblyScript closure support is still in progress and only supports pure nested functions
// Thus, we will pull out the nested function into its own function.
// We pass in the options and results that were previously accessible by the closure, and return the results
// We also typed all of the parameters, to their respective types they would have been in JS or TS.
// One notable parameter is `valueString`. In JavaScript, optional parameters will default to `undefined`.
// In AssemblyScript, we would want to initialize this parameter with a value of its type and check that value later.
// We could have done a `valueString: string | null = null` to also signify it's an optional high-level typed parameter in a more
// JS-like fashion, but we use an empty string as it is a simpler replacement to check for.
// Ported from: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L33
function add(
  options: PrettyMillisecondsOptions,
  result: Array<string>,
  value: f64,
  long: string,
  short: string,
  valueString: string = ""
): Array<string> {
  if (
    (result.length === 0 || !options.colonNotation) &&
    value === 0 &&
    !(options.colonNotation && short === "m")
  ) {
    return result;
  }

  // AssemblyScript doesn't have `undefined`, so we need to be
  // a bit more explicit with our typecheck here
  // Referring to: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L38
  if (valueString == "") {
    valueString = value.toString();
  }

  // AssemblyScript would normally default types to i32, if the compiler can't figure out what
  // the type is from its initial assignment. So we need to define these types as strings,
  // since they are being used as strings
  // Ported from: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L39
  let prefix: string = "";
  let suffix: string = "";
  if (options.colonNotation) {
    prefix = result.length > 0 ? ":" : "";
    suffix = "";
    const wholeDigits = valueString.includes(".")
      ? valueString.split(".")[0].length
      : valueString.length;
    const minLength = result.length > 0 ? 2 : 1;
    valueString =
      "0".repeat(<i32>Math.max(0, minLength - wholeDigits)) + valueString;
  } else {
    prefix = "";

    // Since we removed the `DecimalDigits` options and `keepDecimalsOnWholeSeconds`, as Float.toFixed is in progress:
    // https://github.com/AssemblyScript/assemblyscript/issues/1163
    // Let's remove the trailing `.0` to clean things up by parsing our f64 into an i32
    valueString = I32.parseInt(valueString).toString();

    suffix = options.verbose ? " " + pluralize(long, value) : short;
  }

  result.push(prefix + valueString + suffix);
  return result;
}

// Export a function to parse our milliseconds into our human readable milliseconds string.
// Also, the `i32` type in TypeScript would be `number`, but in AS we must be explicit with the number type.
// Ported from: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L8
export function prettyMilliseconds(
  milliseconds: f64,
  options: PrettyMillisecondsOptions
): string {
  if (!Number.isFinite(milliseconds)) {
    throw new Error("Expected a finite number");
  }

  if (options.colonNotation) {
    options.compact = false;
    options.formatSubMilliseconds = false;
    options.separateMilliseconds = false;
    options.verbose = false;
  }

  // Since we aren't supporting DecimalDigits options in this port, we don't need to modify them
  // Referring to: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L20

  // It is best to define most high-level types by their object and type
  // Therefore our Array is defined as `new Array<string>()` instead of `[]`
  // Ported from: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L25
  let result = new Array<string>();

  const parsed = parseMilliseconds(milliseconds);

  // As mentioned earlier, we pulled the add function into its own function.
  // Thus, we update our result as we add instead of using the closure.
  // We also updated the other `add()` calls below, but only commenting here for brevity.
  // Also, we don't need the Math.trunc() call since we are doing integer division by default, unlike JavaScript
  // Ported from: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L57
  result = add(options, result, Math.floor(parsed.days / 365), "year", "y");
  result = add(options, result, parsed.days % 365, "day", "d");
  result = add(options, result, parsed.hours, "hour", "h");
  result = add(options, result, parsed.minutes, "minute", "m");

  if (
    options.separateMilliseconds ||
    options.formatSubMilliseconds ||
    (!options.colonNotation && milliseconds < 1000)
  ) {
    result = add(options, result, parsed.seconds, "second", "s");
    if (options.formatSubMilliseconds) {
      result = add(options, result, parsed.milliseconds, "millisecond", "ms");
      result = add(options, result, parsed.microseconds, "microsecond", "µs");
      result = add(options, result, parsed.nanoseconds, "nanosecond", "ns");
    } else {
      const millisecondsAndBelow =
        parsed.milliseconds +
        parsed.microseconds / 1000 +
        parsed.nanoseconds / 1e6;

      // Since we aren't supporting DecimalDigits options in this port, we don't need `millisecondsDecimalDigits`
      // Referring to: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L78

      const roundedMilliseconds =
        millisecondsAndBelow >= 1
          ? Math.round(millisecondsAndBelow)
          : Math.ceil(millisecondsAndBelow);

      // Since we aren't supporting DecimalDigits options in this port, we don't need `millisecondsDecimalDigits`
      // Referring to: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L87
      const millisecondsString: string = roundedMilliseconds.toString();

      result = add(
        options,
        result,
        parseFloat(millisecondsString),
        "millisecond",
        "ms",
        millisecondsString
      );
    }
  } else {
    const seconds = (milliseconds / 1000) % 60;

    // Since we aren't supporting DecimalDigits options in this port, we don't need `secondsDecimalDigits`
    // Referring to: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L78

    const secondsString = seconds.toString();
    result = add(
      options,
      result,
      parseFloat(secondsString),
      "second",
      "s",
      secondsString
    );
  }

  if (result.length === 0) {
    return "0" + (options.verbose ? " milliseconds" : "ms");
  }

  if (options.compact) {
    return result[0];
  }

  // We can replace the type check with a `> 0` check since we are using a typed language!
  // Referring to: https://github.com/sindresorhus/pretty-ms/blob/eda21362097d47ab309dca8cf07dc79b25fb0efa/index.js#L119
  if (options.unitCount > 0) {
    const separator = options.colonNotation ? "" : " ";
    return result.slice(0, <i32>Math.max(options.unitCount, 1)).join(separator);
  }

  return options.colonNotation ? result.join("") : result.join(" ");
}

Awesome! Next, let’s port the main Node.js applications logic to an assembly/pretty-execution-time.ts. Remember, we decided to use the Date object from as-wasi. So we would want to install as-wasi into our project by running in our terminal: npm install --save as-wasi. And then create a assembly/pretty-execution-time.ts:

import "wasi";
import { Date } from "as-wasi";
import { prettyMilliseconds } from "./pretty-ms";

export function getPrettyExecutionTime(): string {
  // Get our start time in milliseconds (Unix Timestamp)
  // In "as-wasi" this returns the milliseconds as a float
  // However, we will cast this to an integer for prettyMilliseconds
  const start = Date.now();

  // Do a random amount of busy work
  let iterations = 100000 + Math.floor(Math.random() * 10000000);
  for (let i = 0; i < iterations; i++) {
    iterations -= 1;
  }

  // Get our start time in milliseconds (Unix Timestamp)
  const end = Date.now();

  let responseString = "";
  responseString +=
    "Pretty Unix timestamp: " +
    prettyMilliseconds(start, { verbose: true }) +
    "\n";
  responseString +=
    "Busy work execution time: " +
    prettyMilliseconds(end - start, {
      verbose: true,
      formatSubMilliseconds: true,
    });

  return responseString;
}

Lastly, let’s call our exported getPrettyExecutionTime function in our Compute@Edge entrypoint AssemblyScript file. Let’s modify assembly/index.ts:

import { Request, Response, Fastly } from "@fastly/as-compute";

// Import our pretty-execution time
import { getPrettyExecutionTime } from "./pretty-execution-time";

// Remove the unnecessary backend constants for our application
// Referring to: https://github.com/fastly/compute-starter-kit-assemblyscript-default/blob/78e536b046cff9e2a3e81945ef8b02ddc7bf2a75/assembly/index.ts#L3

// The entry point for your application.
//
// Use this function to define your main request handling logic. It could be
// used to route based on the request properties (such as method or path), send
// the request to a backend, make completely new requests, and/or generate
// synthetic responses.
function main(req: Request): Response {
  // Make any desired changes to the client request.
  req.headers().set("Host", "example.com");

  // We can filter requests that have unexpected methods.
  const VALID_METHODS = ["HEAD", "GET", "POST"];
  if (!VALID_METHODS.includes(req.method())) {
    return new Response(String.UTF8.encode("This method is not allowed"), {
      status: 405,
    });
  }

  let method = req.method();
  let urlParts = req.url().split("//").pop().split("/");
  let host = urlParts.shift();
  let path = "/" + urlParts.join("/");

  // If request is a `GET` to the `/` path, send a default response.
  if (method == "GET" && path == "/") {
    return new Response(String.UTF8.encode(getPrettyExecutionTime()), {
      status: 200,
    });
  }

  // Remove the unnecessary routes for our application
  // Referring to: https://github.com/fastly/compute-starter-kit-assemblyscript-default/blob/78e536b046cff9e2a3e81945ef8b02ddc7bf2a75/assembly/index.ts#L42

  // Catch all other requests and return a 404.
  return new Response(
    String.UTF8.encode("The page you requested could not be found"),
    {
      status: 404,
    }
  );
}

// Get the request from the client.
let req = Fastly.getClientRequest();

// Pass the request to the main request handler function.
let resp = main(req);

// Send the response back to the client.
Fastly.respondWith(resp);

Yay, our application is finished! We can now build and deploy it. Check out the  final example here.

Before we wrap things up, I’d like to give a few closing notes:

  1. A TypeScript equivalent parse-ms and pretty-ms would have been even easier to port. This is because there is some overlap with AssemblyScript and TypeScript types. AssemblyScript types tend to be just a little more specific, so figuring that out and changing those types is much easier than adding them from scratch.

  2. The AssemblyScript compiler tries its best to assume types when variables are declared. However, the assumption can sometimes be different than what you would assume at a first glance. If you notice any weird behavior after a port, a possible fix could be to explicitly add types you had left for the compiler to assume.

  3. pretty-ms and parse-ms are much easier to port than large JavaScript frameworks like Express or Apollo. We chose these packages as they were very popular as well, but could be ported in a brief tutorial format. Larger packages tend to use more parts of the global APIs. For example, a very common Node.js API is their filesystem module fs. fs does have WASI equivalents in as-wasi, which is as-wasi’s FileSystem. But not all global JavaScript APIs in Node.js and/or the browser are compatible since AssemblyScript, WASI, and WebAssembly are all relatively new technologies.

  4. If you port a library that could be used in multiple AssemblyScript projects, and the license would allow you to do so, upload it to npm for the AssemblyScript community! Publishing an AssemblyScript package follows a similar process to uploading a normal JavaScript package. The only difference is that instead of using the “main” key in your package.json, AssemblyScript looks for an “ascMain” in your libraries package.json that should point to the AssemblyScript entry file of your library. For example, if your library’s entryfile is assembly/index.ts, you could add in your package.json: ”ascMain”: “assembly/index.ts”.

AssemblyScript is an exciting new language, and we look forward to seeing what people build with it. We hope this guide shows how similar AssemblyScript is to writing JavaScript or TypeScript, and how AssemblyScript is a great option for bringing JavaScript and TypeScript to Compute@Edge, or into the world of WebAssembly as a whole. Stay in the know about Compute@Edge by signing up for email updates

Share this post

Ready to get started?

Get in touch or create an account.