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:
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:
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!
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!
Lessons learned from running production Go services that call Wasm using Wazero: embedding Wasm binaries, optimizing startup times, pre-initialization, wasm-opt optimizations, and profiling.
Server-side WebAssembly: Unifying cross-language logic for high performance and data privacy. Learn how Arcjet leverages WASM for local-first processing.