WebAssembly
Updated
4 min read

WebAssembly Components at the Edge

How Arcjet is running WebAssembly Components on Vercel's Edge Runtime.

WebAssembly Components at the Edge

We run Arcjet protections as close to our user's code as possible. In many cases, this means compiling our rules to WebAssembly to safely execute adjacent to the runtime, i.e. in the local environment.

Our first SDK was built for Next.js and deploying on Vercel has some interesting challenges to overcome, such as leveraging WebAssembly on their Edge Runtime. This post is a runthrough of how we approached WebAssembly Components at the Edge.

Caveats

The original implementation of our WebAssembly protections were built with wasm-bindgen and wasm-pack, but we quickly hit limitations that needed layers of workarounds. Primarily, these tools expect the code to be used in one specific way, such as nodejs, bundler, web, or no-modules, but that doesn't work for our library that needs to run in various environments, including the Edge Runtime. We needed to generate multiple builds with wasm-pack and conditionally include them to make everything work.

The other caveat is fundamental to the WebAssembly boundary. We found it difficult to share data between the host language and the guest WebAssembly module. When we needed to pass arguments with more complex types, we would use JSON.stringify to create a string we could send to Wasm and we'd have to JSON.parse the string it returned. This allowed us to use Rust types such as HashMap and HeaderMap, but it could be quite tedious and error prone without extra safeguards in place.

WebAssembly Components

The WebAssembly Component Model comes to our rescue. Their primary goal is to:

Define a portable, load- and run-time-efficient binary format for separately-compiled components built from WebAssembly core modules that enable portable, cross-language composition.

For a developer-oriented company like Arcjet, this mission statement checks a lot of boxes!

  • Portable ✅
  • Cross-language ✅
  • Efficient ✅

The Component Model specification also includes the WebAssembly Interface Type format, or WIT for short, which is an IDL for specifying the interface of Components. I won’t dive into it now, but suffice to say, it allows us to create well-defined types instead of using marshaling to-and-from JSON at the Wasm boundary.

For example, in wit we can define the following BotType enum and the various code generators will know how to translate it:

enum bot-type {
    unspecified,
    not-analyzed,
    automated,
    likely-automated,
    likely-not-a-bot,
    verified-bot,
}

enum definition of bot-type in the WebAssembly Interface Types IDL

Code generators

While wit gives us the formal language to define our interfaces, we still need code generation on both sides of the WebAssembly boundary to translate between the types in our language of choice and WebAssembly types. We compile Rust to WebAssembly, so I'll be describing that experience; however, this can apply to C, C++, Java, Go and many other languages.

In our Rust code, we can leverage the wit-bindgen crate to automatically generate the lifting and lowering of types along the WebAssembly boundary. In the following example, I've included a wit file and the Rust code showing how to consume it:

package arcjet:js-req;

world js-req {
  enum bot-type {
    unspecified,
    not-analyzed,
    automated,
    likely-automated,
    likely-not-a-bot,
    verified-bot,
  }

  record bot-detection-result {
    bot-type: bot-type,
    bot-score: u8,
  }

  export detect-bot: func(headers: string, patterns-add: string, patterns-remove: string) -> result<bot-detection-result, string>;
}

world definition for bot detection in the WebAssembly Interface Types IDL

wit_bindgen::generate!({
    world: "js-req"
});

struct JsReqWorld;

export!(JsReqWorld);

impl Guest for JsReqWorld {
  fn detect_bot(headers: String, patterns_add: String, patterns_remove: String) -> Result<BotDetectionResult, String> {
    // bot detection implementation

    Ok(BotDetectionResult {
      bot_type,
      bot_score,
    })
  }
}

Rust implementation of a bot detection world

The first thing to notice is that we're working with normal Rust data types, such as String and Result, even though WebAssembly doesn't know about these. The wit_bindgen::generate! macro invocation takes care of generating the translation layer for these types, including the BotType enum and BotDetectionResult record defined in our wit file!

When we want to consume a Component, we need similar translations on the "host", the language executing the WebAssembly. For our Next.js SDK, the host is a JavaScript runtime, so we need a code generator that supports JavaScript. The jco tool by the Bytecode Alliance allows us to transpile a WebAssembly Component into core WebAssembly modules + a little JavaScript glue that will run in any JavaScript runtime.

WebAssembly & the Edge runtime

As mentioned in the caveats section, loading WebAssembly for various targets can be tricky, but jco gives us the perfect escape hatch. To support running your Component in the Edge Runtime, you need to transpile it with the --instantiation async flag. This gives us complete control over the setup of our WebAssembly modules.

Both Cloudflare and Vercel support WebAssembly on their Edge, but the imports are required to have the ?module suffix. When the Edge platform detects an import like import("arcjet.component.wasm?module") it will instantiate the WebAssembly.Module and return it from the import. We can combine these two details to run our WebAssembly Components on the Edge!

import * as component from "./wasm/arcjet.component.js"

async function moduleFromPath(path: string): Promise<WebAssembly.Module> {
  if (path === "arcjet.component.core.wasm") {
    const mod = await import("./wasm/arcjet.component.core.wasm?module");
    return mod.default;
  }

  if (path === "arcjet.component.core2.wasm") {
    const mod = await import("./wasm/arcjet.component.core2.wasm?module");
    return mod.default;
  }

  if (path === "arcjet.component.core3.wasm") {
    const mod = await import("./wasm/arcjet.component.core3.wasm?module");
    return mod.default;
  }

  throw new Error("Unknown path")
}

const api = await component.instantiate(moduleFromPath);

WebAssembly module instantiation in TypeScript via imports

Just the beginning

Running WebAssembly Components on the Edge is just one piece of the puzzle. We have many more techniques applied in our SDK to ensure our WebAssembly protections work on many different platforms.

It is also very early days for the Component Model, as it just reached v0.2 near the end of 2023. As more languages adopt the Component Model, we'll be able to seamlessly compose different languages compiled to WebAssembly and running in the secure sandbox.

We have much work planned to leverage Components across our product. Keep an eye on Arcjet to see our future WebAssembly updates!

Related articles

Subscribe by email

Get the full posts by email every week.