Bringing Javascript to WebAssembly for Shopify Functions

At Winter Editions 2023 we announced a Local Developer Preview for JavaScript for Shopify Functions. That means that we’re adding JavaScript right next to Rust as our first-class languages for Shopify Functions (but you can still use anything that compiles to WebAssembly!). While you can’t deploy a Shopify Function written in JavaScript just yet, you can run the function locally on your machine and start experimenting right now! Any feedback on our JavaScript runtime and the DX is welcome on the repository for Shopify Functions in JavaScript.

While we’re working on getting our Shopify Functions infrastructure ready for the public beta, we thought we’d use this opportunity to shine some light on how we brought JavaScript to WebAssembly, how we made everything fit within our very tight Shopify Function constraints, and what our plans for the future look like.

Shopify Functions

A platform like Shopify is only as useful as the use cases it can cater to. That means customizability and extendibility are mission critical and are core principles for every project that is developer-facing. Merchants get a great experience out of the box when they sign up with Shopify, but their need for customization and personalization naturally increases over time as their business grows. For cases where a merchant and their developers want to take full control over every detail of their store, we offer the Storefront API. With Hydrogen and Remix, we’re helping developers move faster through their custom journey by setting them up with a well-lit path with baked-in best practices and tooling. Long story short: Developers can customize the front end. With Metafields, we allow developers to store custom data in our database. This is the second piece in the trifecta of customization: Developers can customize the database.

Shopify Functions are the last puzzle piece to complete the trifecta: Developers can customize the back end. Shopify Functions provide a mechanism for developers to inject their custom code and run it on our servers. The long-term goal is to make every part of a store’s pipeline replaceable with custom code, giving Shopify’s platform unprecedented flexibility without sacrificing security or scalability. 

WebAssembly (or Wasm for short) is the key technology here. It’s a perfect fit, as it’s designed around flexibility, security and performance. The strong sandbox of Wasm lets us run untrusted code with confidence, its predictable performance lets us define and impose strict resource limits, and the rich and ever-growing ecosystem of languages that can target Wasm gives developers free choice on how they want to write their custom code.

At the Summer Editions 2022, we launched Shopify Functions with Rust being our recommended language. Not only has Shopify embraced Rust as its default systems programming language, Rust has great support for Wasm and has spawned a rich ecosystem of tooling specifically around Wasm.

The vast majority of the developers building for Shopify are web developers and use JavaScript as their language of choice. We’ve heard the feedback that many developers would prefer to write JavaScript over Rust, and that many Shopify developers were hesitant to give Shopify Functions a try when it meant learning a new programming language. We’re determined to support our developers wherever they need us, so we had to figure out how to run JavaScript via Wasm, which is not very well explored.

WebAssembly/WASI

Every function that runs on the Shopify Functions infrastructure is nothing less and nothing more than a WASI (WebAssembly System Interface) module. No smoke and mirrors or other special technologies are in use. 

Wasm modules by themselves can only do arithmetic. They are completely sandboxed and only get an isolated chunk of memory to work with for their computations. The host system can expose individual functions to the Wasm module, granting granular access to additional capabilities. 

WASI is a standardized collection of those functions and capabilities, with the goal to make Wasm useful outside the browser. If you want to know more about WASI, this blog post by Lin Clark from the BytecodeAlliance, of which Shopify is also a member, is a great introduction. The only part of WASI we rely on is the ability to read from stdin and write to stdout (and, of course, stderr).

There are some additional constraints that every module must fulfill, amongst other things to ensure that we can scale functions appropriately, even on Black Friday:

  1. The module must not be larger than 256KB.
  2. The module must not run longer than 5ms.
  3. The module must consume a JSON-formatted string via stdin and produce a JSON-formatted string on stdout.

Note: 5ms is a very machine-dependent and situational constraint. The same machine will need different amounts of time to execute the exact same function, depending on how the load the machine is under. We’re exploring a gas-like approach as a machine- and situation-independent measurement to give developers confidence that their function is fast enough.

With those constraints in mind, we started searching for a way to run JavaScript inside a WebAssembly VM.

Javy

The “obvious” approach is to take an existing JavaScript engine like V8 or SpiderMonkey and compile it to Wasm. However, most of these high-performance JavaScript engines rely on just-in-time compilers (JITs). In the context of JavaScript, that means that they start out by interpreting the JavaScript code for a while and gathering type information. They then use the type information to generate low-level machine code which yields massive performance gains, to the point where JavaScript can compete with native performance in many scenarios.    

However, at the time of writing, WebAssembly’s architecture makes JIT-ing impossible. There’s no way for Wasm to generate and then execute code, as the memory storing the instructions is completely inaccessible to the module itself. This is by design: By preventing any code that wasn’t present during module instantiation from being executed, almost all remote code execution vulnerabilities become impossible.

For the time being, we have to settle for the more traditional approach: Find a fast (ideally VM-based) interpreter for JavaScript, compile it to Wasm, and colocate the JavaScript code and the engine in the same Wasm binary. 

This is exactly the approach Shopify’s Saúl Cabrera took when he wrote the first version of Javy, a JavaScript-To-WebAssembly toolchain. The goal for Javy is to be a general-purpose tool for anyone who wants to work with JavaScript in Wasm. While we’re motivated by the Shopify Functions use case, we aim to keep Javy general purpose and have no Shopify-specific code shipped in Javy. 

QuickJS

QuickJS is a small but fast JavaScript engine written by none other than Fabrice Bellard. The engine passes the test suite for ES2020 and is written in basic C, which means it can be compiled to Wasm. Because of Shopify’s internal preference for Rust and the strong tooling ecosystem that Rust has for Wasm, we started by writing Rust wrappers for QuickJS, called quickjs-wasm-sys and quickjs-wasm-rs.

Javy has a somewhat unorthodox build setup with two stages. In the first stage, it will compile a small Rust program to a Wasm module using the standard Rust compiler. This program uses the QuickJS crates above to create an instance of the QuickJS engine inside the Wasm module, and hooks up stdin and stdout (more on that later). In the second stage, the build process will compile the actual Javy CLI, which will bundle the Wasm binary with the executable. 

Without going into too much detail of this approach, here’s how the CLI turns JavaScript into a single Wasm module: When CLI is executed by the user, it will do some Wizer trickery to let QuickJS translate JavaScript code to QuickJS bytecode and then take a snapshot, resulting in a new WebAssembly/WASI module that contains the bytecode of the user’s code. Executing that new Wasm module with Wasmtime will execute that bytecode using the QuickJS VM. 

The problem that we encountered here is not a functional one—this approach is fully functional! The problem is that the Wasm binary created in this process is at least 800KB large and therefore ineligible for Shopify Functions. To reduce the module size, we considered removing features like the parser, support for RegExps, ArrayBuffers, and other likely unused features from the engine. This is problematic as it would lead to an almost-JavaScript runtime, which can be very frustrating to developers. But more critically, early attempts showed it wouldn’t actually get us under the threshold. Our most aggressive approaches got us to about 350KB.

Dynamic Linking

If we were building a native binary, we could just put the engine in a shared library and create a dynamically linked executable, drastically reducing the file size of the executable itself and only having to keep a single copy of the dynamic library in memory. Wasm doesn’t have support for dynamic linking yet, but it’s an active area of research and standardization. The Component Model proposal is looking likely to advance through the stages and paints a promising future as Luke Wagner shows in this presentation. Until the Component Model becomes reality and is supported in runtimes like Wasmtime, we had to come up with a different solution for dynamic linking that also doesn’t prevent us from pivoting to the Component Model later on.

Shopify’s Jeff Charles did a lot of work here and utilized Wasmtime’s linker, which connects and resolves the imported items of a Wasm module with the exported items provided by the host system or other modules. This functionality of the linker is also exposed to the Wasmtime CLI for example via the --preload flag. With this in mind, we designed a minimal interface for our dynamic library “javy_quickjs_provider_v1”, that consists of only two functions:

  • realloc(old_ptr, old_size, alignment, new_size): Used to allocate, resize or free memory allocations.
  • eval_bytecode(ptr, size): runs the given bytecode in a fresh QuickJS instance.

We changed the small program from earlier to fit the new interface requirements. By default, Javy will continue to emit “statically linked” binaries, generated as described above. Those modules run out of the box with Wasmtime. Dynamic linking can be enabled using the -d flag.  

With this in place, the process of generating the Wasm module for the developer’s code is actually a lot simpler than before. When given a JavaScript program, Javy uses the Wasmtime and the quickjs-wasm-sys crate to turn the JavaScript into bytecode. It then generates a minimal, hand-crafted Wasm module that declares the necessary imports for the provider library and calls eval_bytecode with the given bytecode that has been embedded as well:

We also embed the original source code in a Wasm custom section (a section that doesn’t affect execution whatsoever). We do this so we can know which of the Javy runtime APIs are in use.

The WAT code above compiles to a Wasm module of a whopping 220 bytes plus the size of the bytecode—a drastic reduction in file size. To create and run a dynamically linked binary, you can use Javy as follows:

This means that we can not only share the provider amongst many Shopify Functions, we can also aggressively precompile and optimize it ahead of time. We can even deploy bug fixes and optimizations to the engine without developers having to redeploy their function, as long as there are no breaking changes to the interface or the semantics of the bytecode.

As mentioned above, we use WASI to get access to stdin and stdout. But how does a JavaScript program read from stdin? This and other interactions with the host environment is what we group under the term “Javy runtime”.

Runtime

QuickJS is fully ES2020 compliant, meaning it supports Strings, Arrays, Objects and all the methods that come with that. Even ES Modules, JSON parsing, RegExps, ArrayBuffers and their views are implemented. Everything that is specified in the JavaScript specification (ECMA262) is built into QuickJS. However, most developers are used to more than that. Developers want to read files from disk, make network requests, or access peripheral hardware. The web platform has provided a lot of precedent for APIs for these use cases. However, whenever neither JavaScript nor the web provided a solution for a given problem, JavaScript runtimes had to get creative and come up with their own APIs to fill those gaps. 

We need to unlock the primary use cases for Javy, but at the same time we want to avoid inventing yet another set of custom APIs. For that reason, we’ve kept the Javy Runtime APIs extremely minimal. In the future we plan to closely follow the WinterCG, which we’re also founding members of. The WinterCG’s goal is to make JavaScript runtimes (like Deno, CloudFlare Worker and others) more interoperable, meaning that if your code runs in one of these runtimes, it should also run in the other runtimes. In the long term, we aim for Javy to be WinterCG compliant.

Input/Output

The biggest question we needed to answer is how developers can read from stdin and write to stdout using JavaScript. Sadly, none of JavaScript, the Web or the WinterCG provide a standardized solution here. For this purpose, we decided to introduce a Javy global (inspired by the Deno global of Deno), which will contain all of our Javy-specific, non-standard APIs. As of now, we provide two low-level functions here that closely resemble POSIX API calls:

These functions are intentionally very low-level to be flexible, but this makes them inconvenient to use. To give developers more convenient and intuitive APIs, we published the javy library on npm which provides higher-level functions like readFileSync and writeFileSync.

Text Encodings

You may have noticed that all these I/O APIs are working on UInt8Arrays, meaning they handle binary data. In the context of Shopify Functions we want to read (JSON-formatted) strings from stdin, so we need to convert from an ArrayBuffer to a JavaScript string. The web has solved this problem through the introduction of TextEncoder and TextDecoder. These APIs are standardized in the WHATWG, the standards body looking after HTML. As only the JavaScript spec is implemented by QuickJS, TextEncoder et al. are not present in QuickJS by default. We considered using one of the high-fidelity JS polyfills that are available on npm, but for performance reasons we decided to write a new implementation in Rust. To make sure that our implementation behaves the same as the ones developers are used to from the Web and Node, we're now also including some tests from the Web Platform Test suite in Javy. As of now, we have decided to only support encoding to and decoding from UTF-8. While the spec technically demands support for decoding many other encodings, we think it is currently not worth the effort.

Event Loop

Similarly, we haven’t enabled the event loop in the QuickJS instance that Javy uses. That means that async/await, Promises, and functions like setTimeout are syntactically available but never trigger their callbacks. We want to enable this functionality in Javy, but have to clear up a couple of open questions, like if and how to integrate the event loop with the host system or how to implement setTimeout() from inside a WASI environment.

With all of this in place, we’ve built a JavaScript runtime that behaves very similarly to other runtimes out there. We can generate WASI-compatible Wasm modules from JavaScript and even split them into a static engine part and a dynamic user code part. This forms the foundation of how we brought JavaScript to Shopify Functions. As I said, we want Javy to be a general-purpose tool for JavaScript-in-Wasm, so everything that is specific to Shopify Functions needs to be built on top of Javy. 

Developer Experience

After trying to write a couple of small Shopify Functions with Javy directly, we realized a couple of easy wins. Every function has the same boilerplate: We start by reading bytes from stdin until we reach the end of the stream, turning the sequence of bytes into a string, and parsing that string as JSON. Then we run the actual business logic of the function. Afterwards, the result of the business logic has to be turned back into a JSON-formatted string, the string converted into a byte stream, and the byte stream piped back to stdout. This boilerplate should not have to be written by developers over and over.

The other realization was that we approached Javy and Shopify Functions with different expectations. Javy is a JavaScript runtime and should behave as such. That means that the script you pass to Javy should get executed top-to-bottom and you can call other functions from the global scope. Shopify Functions, on the other hand, are more akin to edge runtimes like AWS Lambda, Netlify Functions, or Cloudflare Workers, meaning the code by the user provides an explicit entry point and the underlying system invokes that entry point for every “request”. 

To implement these changes, we had to write a little layer between Javy and the user code that facilitates both the boilerplate and that inversion of control (in other words, the developer’s function gets called instead of the developer calling other functions). This layer is published to npm under @shopify/shopify_function, and the core logic is simple but effective:

The trick here is that instead of using the developer’s function code as the entry point, we define this library code as the entry point for Javy. The code starts by doing the boilerplate work and then invokes the developer’s function. To make the imports work, we could have implemented module resolution and multi-file handling in Javy. However, a much easier approach is to run a bundler like ESBuild as part of our function build step to inline all dependencies into one big JavaScript file. For that process, we define an alias that makes user-function point to the developer’s code. As a nice side effect, the developer is also able to use libraries from npm like they are used to (for example, i18n) if they so desire. You don’t have to set any of this up yourself, either, as we wrote a template that you can use through the Shopify CLI (details below!).

GraphQL & TypeScript

Many of our Shopify Function extension points rely on data from our GraphQL API. To help developers handle that data correctly, we’re using @graphql-codegen/cli in our template to generate TypeScript type definitions from our GraphQL schema and the GraphQL query. While developers have the choice whether to write vanilla JavaScript or TypeScript (we support both!) we generate those type definitions in the background either way as they enable helpful autocomplete prompts in your editor like VS Code. GraphQL response objects can get a bit unwieldy at times! 

Example

To make use of our local developer preview, you have to opt in to our preview and create a new app using our CLI and add a JavaScript functions extension:

You can find the source code of your newly created Shopify Function extension in extensions/<extension name>/src/main.ts (or .js if you chose JavaScript). The initial code expects nothing.

To compile this function to Wasm, run npm run build in the extension’s folder which will output the Wasm module to dist/function.wasm. You can run that module locally using Wasmtime, our function-runner or through the Shopify CLI:

You can learn more about Shopify Functions and JavaScript on Shopify.dev.

Performance & The Future

As mentioned, Shopify Functions have very tight performance constraints. A function must not take longer than 5ms, which is why we advocated for Rust when we first released Shopify Functions. First tests on our end have shown that the same business logic run as a Javy-created Wasm module is about 3x slower than a Rust-created Wasm module. However, for realistic use-cases, both modules are likely to run in under 5ms, so JavaScript is a viable option to write Shopify Functions with.

Mozilla’s SpiderMonkey & WebAssembly

Experience says that any performance budget will be saturated by developers. We want Shopify Functions to become available in all parts of the Shopify Platform, and we want to support developers even in their most complex endeavors, so performance will remain crucial. To make the runtime performance of JavaScript for Shopify Functions future proof, we have contracted with Igalia to bring Mozilla’s SpiderMonkey to Wasm. Igalia is an open-source consultancy and one of the biggest contributors to projects like Google’s Blink engine or Apple’s WebKit engine. Bringing SpiderMonkey to Wasm should unlock significant performance improvements compared to QuickJS. We're also actively investing into research and working with the WebAssembly WG to augment the WebAssembly standard to make Just-In-Time compilation possible. It’s very early on for this effort, but we think many dynamic languages will likely benefit from the ability to ship JITs to Wasm.

Local Developer Preview

Developers are not yet able to deploy JavaScript functions to production. We still have some testing to do and we want to make sure that our JavaScript runtime has all the capabilities developers need to build projects. If you’re a Shopify developer and you decide to try the preview, we want to know if you run into problems, find bugs or have other feedback. Please share your thoughts with us by creating an issue on the repository. We’ll open up deployment of JavaScript functions starting with a beta phase in the coming months!

We’re incredibly excited to bring JavaScript to Shopify Functions and hope it makes Functions more attractive to an even larger chunk of our developer audience. We can’t wait to see what the community builds!

This post was written by Surma. DX at Shopify. Web Platform Advocate. Craving simplicity, finding it nowhere. He/him. Internetrovert 🏳️‍🌈


Find him on Twitter, GitHub, or at surma.dev.

We all get shit done, ship fast, and learn. We operate on low process and high trust, and trade on impact. You have to care deeply about what you’re doing, and commit to continuously developing your craft, to keep pace here. If you’re seeking hypergrowth, can solve complex problems, and can thrive on change (and a bit of chaos), you’ve found the right place. Visit our Engineering career page to find your role.